Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
T
lhk229 e57d81454f 合并 master:首页创作类型选择器接管做方案入口,startMode 改为派生
master 这 22 个提交里有一处和本分支正面撞车:它把首页那枚选择器做成了三类型
(做游戏 / 做素材 / 做方案,HomeCreationType),而本分支此前把同一枚控件砍成了
两项(做游戏 / 做方案)并直接用它当路由(ProjectStartMode)。做素材必须与 master
一致,所以取 master 的三类型控件,startMode 不再是独立状态,改为从 creationType
派生:doc → planning,其余 → direct-build。两个字段在 LauncherProjectContext 和
HomeDraft 里并存,creationType 记入口原始类型,startMode 记链路路由。

五处冲突的处理:

- app/types.ts、useHomeProjectCreation.ts:creationType 与 startMode 并存,调用
  顺序统一跟已自动合并好的函数签名走。
- view/home/index.tsx:选择器整段取 master,删掉本分支已无人引用的
  HOME_START_MODES / HOME_START_MODE_ITEMS,startMode 就地派生。
- SupervisorChatOnlyView.tsx:master 新增的 directCodex 活动时间分支保留,内层
  runtime 换成本分支的 projectedRuntime;master 那句
  `!directCodex && synchronizingAcceptedRun` 是冗余的(合并后
  synchronizingAcceptedRun 的定义里已经带了 !directCodex),它重复声明的
  activeCollaboratingRuntimes 也丢掉——上面已经声明过一次。收束判据取本分支的
  runtimeTerminal / descendantsStillActive。
- styles.css:master 的聊天气泡样式与本分支的审批卡高度上限互不相干,都留;补上
  master 最后一条规则缺的闭合括号(它原本借用冲突块外那个共用的 })。

两处测试跟着改:

- appSurface/harness.ts:afterEach 补一次 useLauncherHomeDraftStore.reset()。
  首页创作类型存在 module 级 zustand store 里,跨用例不会回到初始值;合并后建项
  按钮的文案依赖它(做方案 → 进入立项策划),上一个选过做方案的用例会让下一个
  找不到「开启创作」。合并前 startMode 是组件 useState,这个泄漏看不出来。
- appSurface/home.suite.ts:master 新增的用例在选中做方案后仍按「开启创作」找
  按钮,改成「进入立项策划」——本分支有意在这一档改了文案,suite 里本分支自己的
  用例也是按这个口径找的。

验证:npm run typecheck 干净;eslint 改动文件零告警;cargo check --all-targets
通过;appSurface 403/403。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:33:56 +00:00

11694 lines
386 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 } from '../../src/App';
import type {
AgentRuntimeEventRecord,
AgentRuntimeState,
} from '../../src/app/types';
import {
AgentRuntimeStatusPanel,
latestGameChatPlayableRevision as projectLatestGameChatPlayableRevision,
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
projectCurrentGameChatRuntimeLineage,
ProjectSupervisorRuntimePanel,
} from '../../src/features/agent-runtime';
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,
pickProjectFromLauncher,
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,
source,
}: {
agentId?: string;
taskId?: string;
sessionId?: string;
runId?: string;
eventType?: string;
status?: string;
phase?: string;
summary: string;
detail?: string | null;
eventId?: string;
publicText?: string | null;
source?: string;
updatedAt: number;
}): AgentRuntimeEventRecord {
return {
schemaVersion: 'game-creator-runtime-event.v1',
agentId,
taskId,
sessionId,
runId,
source:
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,
};
}
function latestGameChatPlayableRevision(
root: AgentRuntimeState,
runtimeByAgentId: Record<string, AgentRuntimeState>,
) {
return projectLatestGameChatPlayableRevision(
projectCurrentGameChatRuntimeLineage(root, runtimeByAgentId),
);
}
function gameChatMainRuntimeWithEvents(
root: AgentRuntimeState,
events: AgentRuntimeEventRecord[],
) {
const main = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: `${root.runId}-main-session`,
runId: `${root.runId}-main-run`,
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
status: 'running',
phase: 'execution',
updatedAt: root.updatedAt,
});
main.recentEvents = events.map((event) => ({
...event,
agentId: main.agentId,
taskId: main.taskId,
sessionId: main.sessionId,
runId: main.runId,
source: main.source,
}));
return main;
}
function gameChatPlayableMainRuntime({
parentRunId,
revision,
updatedAt,
}: {
parentRunId: string;
revision: number;
updatedAt: number;
}): AgentRuntimeState {
const runId = `code-prototype-${parentRunId}-${revision}`;
const sessionId = `code-prototype-session-${revision}`;
return gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId,
runId,
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId,
status: 'completed',
phase: 'completed',
currentTask: '完成单主静态自检与浏览器试玩验证',
currentGoal: '确认当前 revision 可以试玩',
currentAction: '静态自检与浏览器试玩验证已通过',
recentEvents: [
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId,
runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
status: 'completed',
phase: 'tool-observation',
summary: 'command.run_limited:ok · game.static_smoke 已完成',
updatedAt: Math.max(0, updatedAt - 1),
}),
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId,
runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
status: 'completed',
phase: 'tool-observation',
summary: 'preview.validate:ok · 浏览器验证已通过',
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 = gameChatPlayableMainRuntime({
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');
const playButton = screen.getByRole('button', {
name: '播放',
}) as HTMLButtonElement;
expect(playButton.disabled).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-player;historical → 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*72px 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/,
);
// 这一列的成员数量随链路变化(做方案会多出阶段进度条与审批卡),所以「谁伸缩」必须由
// 类名决定而不是由出现顺序决定:一旦回到按位置分配轨道,审批卡就会落到能被压到 0 的
// 那条上、末尾成员溢出后被 overflow: hidden 裁掉。
expect(styles).toMatch(
/\.project-supervisor-conversation\s*\{[^}]*display:\s*flex[^}]*flex-direction:\s*column/s,
);
expect(styles).not.toMatch(
/\.project-supervisor-conversation\s*\{[^}]*grid-template-rows/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s,
);
expect(styles).toMatch(
/\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*flex:\s*1 1 auto[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto/s,
);
// 审批卡自带上限并内部滚动,不能靠挤别人来容纳决定项。
expect(styles).toMatch(
/\.game-workbench-chat \.gdd-approval-card\s*\{[^}]*max-height:\s*clamp\(160px, 34dvh, 380px\)[^}]*overflow-y:\s*auto/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,
);
expect(styles).toMatch(
/\.game-run-surface\s*\{[^}]*grid-template-rows:\s*minmax\(300px, 1fr\) auto[^}]*grid-row:\s*2 \/ -1[^}]*height:\s*100%/s,
);
expect(styles).toMatch(
/\.game-run-preview iframe\s*\{[^}]*position:\s*absolute[^}]*inset:\s*0[^}]*width:\s*100%[^}]*height:\s*100%[^}]*min-height:\s*0/s,
);
expect(styles).not.toMatch(/\.game-run-slice-controls/);
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('keeps workbench chat bubbles aligned without shrinking process cards', () => {
const styles = readFileSync(
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
'utf8',
);
const messageListRule =
styles.match(
/\.game-workbench-chat \.project-supervisor-message-list\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const messageRule =
styles.match(
/\.game-workbench-chat \.project-supervisor-message-list \.message\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const userMessageRule =
styles.match(
/\.game-workbench-chat \.project-supervisor-message-list \.message--user\s*\{([^}]*)\}/s,
)?.[1] ?? '';
const processCardRules = Array.from(
styles.matchAll(
/\.game-workbench-chat\s+\.project-supervisor-message-list\s*>\s*\.project-supervisor-process-card\s*\{([^}]*)\}/gs,
),
(match) => match[1],
);
expect(messageListRule).not.toBe('');
expect(messageListRule).not.toContain('display: flex;');
expect(messageListRule).not.toContain('align-items:');
expect(messageRule).toContain('width: fit-content;');
expect(messageRule).toContain('max-width: min(88%, 720px);');
expect(userMessageRule).not.toBe('');
expect(userMessageRule).toContain('margin-left: auto;');
expect(userMessageRule).not.toContain('align-self:');
expect(userMessageRule).toContain('border-radius: 14px 14px 4px;');
expect(userMessageRule).toContain('background: var(--platform-warm-bg);');
expect(userMessageRule).toContain('color: var(--platform-text-base);');
expect(processCardRules).toHaveLength(1);
expect(processCardRules[0]).toContain('width: 100%;');
});
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 } };
const onPlay = vi.fn();
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(),
onPlay,
}),
);
fireEvent.click(screen.getByRole('button', { name: '播放' }));
expect(onPlay).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText('运行表现层')).not.toBeNull();
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.queryByLabelText('测试切片控件')).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();
expect(
screen.getByText('点击顶部播放按钮后将在这里直接运行游戏'),
).not.toBeNull();
expect(screen.queryByText(/\/run|\/preview/)).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('routes a top workbench play request into the existing game.run_local confirmation', async () => {
const projectPath = '/tmp/top-play-request';
const supervisorHarness = 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: '顶部播放请求',
recentRunStatus: null,
recentRunStopReason: null,
};
}
return supervisorHarness.invoke(command, args);
},
);
const handled = vi.fn();
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
playRequest: { projectPath, requestId: 7 },
onPlayRequestHandled: handled,
}),
);
expect(
await screen.findByText('game.run_local', {}, { timeout: 3_000 }),
).not.toBeNull();
expect(handled).toHaveBeenCalledWith(7);
expect(screen.getByText(`启动 ${projectPath}/game/`)).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'start_local_game_preview',
expect.anything(),
);
});
it('starts a direct product preview without the legacy Canvas-only static smoke', async () => {
const projectPath = '/tmp/direct-dom-preview';
const manifest = createGameCreationAppManifest(
'direct-dom-preview',
'DOM 游戏预览',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43126/',
port: 43126,
root: projectPath,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
initialProjectManifest: manifest,
projectSupervisorOnly: true,
playRequest: { projectPath, requestId: 8 },
}),
);
expect(await screen.findByText('game.run_local')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(
'运行通过,已载入客户端运行视图:http://127.0.0.1:43126/',
),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('start_local_game_preview', {
projectPath,
});
expect(invoke).not.toHaveBeenCalledWith('run_limited_local_command', {
projectPath,
commandId: 'game.static_smoke',
});
});
it('keeps the direct Codex welcome surface free of legacy Supervisor status 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');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
const messageList = within(supervisorSurface).getByLabelText('陶泥儿消息');
expect(
await within(messageList).findByText('想做什么游戏?'),
).not.toBeNull();
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
).toBeNull();
expect(
within(supervisorSurface).getByPlaceholderText(
'告诉陶泥儿接下来要做什么',
),
).not.toBeNull();
expect(
within(supervisorSurface).getByRole('button', { name: '发送' }),
).toHaveProperty('disabled', false);
expect(screen.queryByLabelText('项目总控对话')).toBeNull();
});
it('keeps a persisted legacy Supervisor failure out of the direct product chat', async () => {
const projectPath = '/tmp/launcher-legacy-failure-supervisor-game';
const legacyFailure =
'后台任务失败:kind=codex-app-server-context-window-exceeded ' +
`fingerprint=${'a'.repeat(64)} chars=2048`;
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
supervisorMessages: [
{
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content: legacyFailure,
agentId: 'project-supervisor',
messageId: 'legacy-failure-message',
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-legacy-failure-supervisor-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return createGameCreationAppManifest(
'local-project-draft',
'launcher-legacy-failure-supervisor-game',
);
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const messageList = await screen.findByLabelText('陶泥儿消息');
expect(
within(messageList).queryByText(
'项目总控 Agent 模型上下文已超限,请缩小任务范围后重试',
),
).toBeNull();
expect(messageList.textContent).not.toContain('fingerprint');
expect(messageList.textContent).not.toContain('chars=');
});
it('keeps a persisted needs-reconciliation Supervisor runtime out of direct product chat', 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 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;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
).toBeNull();
expect(
within(supervisorSurface).queryByText('项目总控 Agent · 待核对'),
).toBeNull();
expect(
within(supervisorSurface).queryByText('tool-plan-unknown'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
expect.anything(),
);
expect(
within(supervisorSurface).queryByRole('button', {
name: '已核对,结束旧任务',
}),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'resume_game_creator_agent_runtime_tasks',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'cancel_game_creator_agent_runtime_task',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
expect.anything(),
);
expect(
within(supervisorSurface).queryByRole('button', {
name: '在当前项目重试总控',
}),
).toBeNull();
});
it('does not hydrate a reconciled legacy Supervisor while allowing a new direct Codex turn', 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 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 === 'chat_with_game_creator_direct_codex') {
return 'DIRECT_AFTER_RECONCILIATION_OK';
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43123/game/index.html',
port: 43123,
root: projectPath,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 状态'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
expect.anything(),
);
const input = within(supervisorSurface).getByLabelText('陶泥儿对话内容');
fireEvent.change(input, {
target: { value: '从旧任务状态继续生成,但使用 direct Codex' },
});
fireEvent.submit(input.closest('form') as HTMLFormElement);
expect(
await within(supervisorSurface).findByText(
'DIRECT_AFTER_RECONCILIATION_OK',
),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', {
projectPath,
prompt: '从旧任务状态继续生成,但使用 direct Codex',
clientTurnId: expect.any(String),
});
expect(invoke).not.toHaveBeenCalledWith(
'cancel_game_creator_agent_runtime_task',
expect.anything(),
);
expect(invoke).not.toHaveBeenCalledWith(
'confirm_retry_game_creator_agent_runtime_task',
expect.anything(),
);
});
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('keeps External Editor credentials hidden from ordinary 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: {
agentMode: 'codex_app_server',
llm: {
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: {
baseUrl: 'http://127.0.0.1:8082',
apiKey: 'game-chat-editor-secret',
},
mcpServers: {},
},
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(App, {
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
fireEvent.click(screen.getByRole('button', { name: '设置' }));
expect(
await screen.findByRole('dialog', { name: '运行时配置' }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /连接与工具/ }));
expect(screen.queryByLabelText('External Editor Base URL')).toBeNull();
expect(screen.queryByLabelText('External Editor API Key')).toBeNull();
expect(screen.queryByDisplayValue('game-chat-editor-secret')).toBeNull();
});
it('shows External Editor credentials only in the standalone game-chat release', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'read_game_creator_app_config') {
return {
path: '/home/test/AppData/game-chat/game-creator.config.json',
config: {
agentMode: 'codex_app_server',
llm: {
apiKey: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1',
apiKind: 'openai_responses',
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: {
baseUrl: 'https://editor.example.test',
apiKey: 'game-chat-editor-secret',
},
mcpServers: {},
},
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(GameChatReleaseApp, {
allowAdvancedExternalEditorConfig: true,
}),
);
fireEvent.click(screen.getByRole('button', { name: '设置' }));
expect(
await screen.findByRole('dialog', { name: '运行时配置' }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: /连接与工具/ }));
expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty(
'value',
'https://editor.example.test',
);
expect(screen.getByLabelText('External Editor API Key')).toHaveProperty(
'value',
'game-chat-editor-secret',
);
});
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 direct Codex game-chat message per page across StrictMode remounts', 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_manifest') {
return manifest;
}
if (command === 'chat_with_game_creator_direct_codex') {
return `DIRECT_INITIAL_REPLY:${String(args?.prompt ?? '')}`;
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
const renderInitialMessageApp = (message: string) =>
React.createElement(
React.StrictMode,
null,
React.createElement(GameChatReleaseApp, {
initialProjectPath: projectPath,
initialSupervisorMessage: message,
}),
);
const rendered = render(renderInitialMessageApp(prompt));
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(1);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(0);
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,
).toBe(initCallCount);
expect(
(screen.getByLabelText('陶泥儿对话内容') as HTMLTextAreaElement)
.disabled,
).toBe(false);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(0);
});
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: expect.stringMatching(/^local-project-/),
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('游戏运行', {}, { timeout: 3000 }),
).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 currentMain = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'code-session',
runId: 'code-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: 'active-parent-run',
updatedAt: 30,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'code-prototype',
sessionId: 'code-session',
runId: 'code-run',
source: 'agent-ready-task-scheduler',
summary: '当前主 Agent 事件',
updatedAt: 30,
}),
gameChatRuntimeEvent({
agentId: 'code-prototype',
sessionId: 'code-session',
runId: 'previous-code-run',
source: 'agent-ready-task-scheduler',
summary: '主 Agent 旧 run 事件',
updatedAt: 60,
}),
],
});
const currentArtChild = gameChatRuntimeState({
agentId: 'art-asset-plan',
taskId: 'art-asset-plan',
sessionId: 'current-art-session',
runId: 'current-art-run',
source: 'agent-delegate',
parentAgentId: 'code-prototype',
parentRunId: 'code-run',
updatedAt: 25,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'art-asset-plan',
sessionId: 'current-art-session',
runId: 'current-art-run',
source: 'agent-delegate',
summary: '当前美术 child 事件',
updatedAt: 25,
}),
],
});
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, {
'code-prototype': currentMain,
'current-art-asset-plan': currentArtChild,
'stale-art-asset-plan': staleChild,
});
expect(events.map((item) => item.event.summary)).toEqual([
'当前主 Agent 事件',
'当前美术 child 事件',
'总控事件 2',
'总控事件 1',
]);
expect(events.map((item) => item.agentLabel)).toEqual([
'程序原型 Agent',
'美术资源计划 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 mainRuntime = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'code-prototype-session',
runId: 'code-prototype-main-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: runId,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'code-prototype',
sessionId: 'code-prototype-session',
runId: 'code-prototype-main-run',
source: 'agent-ready-task-scheduler',
eventType: 'turn.started',
summary: 'Code prototype Agent started and must stay visible',
updatedAt: 32,
}),
],
updatedAt: 32,
});
const messages = gameChatRuntimeEventMessages(runtime, {
'code-prototype': mainRuntime,
});
expect(messages.map((message) => message.updatedAt)).toEqual([
32, 40, 60, 80,
]);
expect(messages.map((message) => message.text)).toEqual([
expect.stringContaining(
'Code prototype 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([]);
}
});
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(3);
expect(messages.map((message) => message.text)).toEqual([
expect.stringContaining('视觉方向已完成'),
expect.stringContaining('平台美术图集已生成并登记'),
expect.stringContaining('代码原型已完成'),
]);
expect(messages[0]?.messageId).toContain('art-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('hides generic retry for exact game-chat dynamic art children and shows cross-round recovery guidance', () => {
const root = gameChatRuntimeState({
runId: 'game-chat-dynamic-art-retry-root',
status: 'running',
phase: 'waiting-for-delegate-receipts',
});
const main = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'game-chat-dynamic-art-main-session',
runId: 'game-chat-dynamic-art-main-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
status: 'running',
phase: 'waiting-for-delegate-receipts',
});
const artDirector = gameChatRuntimeState({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'game-chat-dynamic-art-director-session',
runId: 'game-chat-dynamic-art-director-run',
source: 'agent-delegate',
parentAgentId: 'code-prototype',
parentRunId: main.runId,
status: 'failed',
phase: 'failed',
currentAction: '动态美术委派失败',
});
const legacyRetry = gameChatRuntimeState({
agentId: 'art-asset-plan',
taskId: 'art-asset-plan',
sessionId: 'game-chat-dynamic-art-retry-session',
runId: 'game-chat-dynamic-art-retry-run',
source: 'agent-delegate-retry',
parentAgentId: 'code-prototype',
parentRunId: main.runId,
status: 'failed',
phase: 'failed',
currentAction: '遗留 retry 只作诊断展示',
});
const wrongParentArt = gameChatRuntimeState({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'game-chat-wrong-parent-art-session',
runId: 'game-chat-wrong-parent-art-run',
source: 'agent-delegate',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
status: 'failed',
phase: 'failed',
currentAction: '旧 root 直属美术不得显示',
});
const callbacks = {
onProfessionalRetry: vi.fn(async () => 'unexpected retry'),
onProfessionalToolAction: vi.fn(async () => {}),
onSupervisorRetry: vi.fn(async () => 'unexpected supervisor retry'),
onToolAction: vi.fn(),
onUserInput: vi.fn(),
};
const renderPanel = (runtime: AgentRuntimeState) =>
React.createElement(ProjectSupervisorRuntimePanel, {
runtime,
error: '',
runtimeByAgentId: {
'code-prototype': main,
'art-director': artDirector,
'art-asset-plan': legacyRetry,
'wrong-parent-art': wrongParentArt,
},
controlBusy: false,
professionalResultsByAgentId: {},
...callbacks,
});
const rendered = render(renderPanel(root));
const list = screen.getByLabelText('专业 Agent 实时状态');
expect(
within(list).queryByRole('button', { name: '在当前项目重试' }),
).toBeNull();
expect(list.textContent).not.toContain('旧 root 直属美术不得显示');
expect(callbacks.onProfessionalRetry).not.toHaveBeenCalled();
rendered.rerender(
renderPanel({ ...root, status: 'completed', phase: 'completed' }),
);
expect(
within(screen.getByLabelText('专业 Agent 实时状态')).getAllByText(
'请继续 game-chat 对话,由下一轮程序原型 Agent 重新审计缺口后委派',
),
).toHaveLength(2);
});
it('disables generic retry when a selected runtime claims nested game-chat art lineage', () => {
const onRetryRuntimeTask = vi.fn();
const nestedArt = gameChatRuntimeState({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'selected-game-chat-art-session',
runId: 'selected-game-chat-art-run',
source: 'agent-delegate',
parentAgentId: 'code-prototype',
parentRunId: 'selected-game-chat-main-run',
status: 'failed',
phase: 'failed',
});
const rendered = render(
React.createElement(AgentRuntimeStatusPanel, {
runtime: nestedArt,
onRetryRuntimeTask,
}),
);
const retryButton = within(
screen.getByLabelText('Agent Runtime 操作'),
).getByRole('button', { name: '重试' }) as HTMLButtonElement;
expect(retryButton.disabled).toBe(true);
fireEvent.click(retryButton);
expect(onRetryRuntimeTask).not.toHaveBeenCalled();
rendered.rerender(
React.createElement(AgentRuntimeStatusPanel, {
runtime: {
...nestedArt,
parentAgentId: 'project-supervisor',
parentRunId: 'full-dag-root-run',
},
onRetryRuntimeTask,
}),
);
expect(
(
within(screen.getByLabelText('Agent Runtime 操作')).getByRole(
'button',
{ name: '重试' },
) as HTMLButtonElement
).disabled,
).toBe(false);
});
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 mainRunId = `code-prototype-${runId}`;
const makeRuntimeResult = (
agentId: string,
status: 'ready' | 'committed',
text: string,
updatedAt: number,
) => {
const currentMain = agentId === 'code-prototype';
const currentDynamicArt = ['art-director', 'art-asset-plan'].includes(
agentId,
);
const state = gameChatRuntimeState({
agentId,
taskId: agentId,
sessionId: `${agentId}-session-active`,
runId: currentMain ? mainRunId : `${agentId}-${runId}`,
source: currentMain
? 'agent-ready-task-scheduler'
: currentDynamicArt
? 'agent-delegate'
: 'agent-ready-task-scheduler',
parentAgentId: currentDynamicArt
? 'code-prototype'
: 'project-supervisor',
parentRunId: currentDynamicArt ? mainRunId : 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: state.sessionId,
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.queryByText(/预览就绪已完成/)).toBeNull();
expect(screen.queryByText(/试玩验证已完成/)).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(3);
});
rendered.unmount();
rendered = renderRelease();
await waitFor(() => {
expect(screen.getByText(/代码原型已完成/)).not.toBeNull();
});
expect(finalReplyAppends()).toHaveLength(3);
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,
source: 'project-supervisor-game-chat',
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.inspect:ok · 视觉检查已完成,共分析 2 张图片',
detail: JSON.stringify({
inspectionKind: null,
validationProfile: null,
passed: null,
checks: null,
issues: null,
}),
updatedAt: 25,
}),
],
});
const progress = buildGameChatProgressEvidence(
runtime,
{
'code-prototype': gameChatMainRuntimeWithEvents(
runtime,
runtime.recentEvents ?? [],
),
},
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.inspect:failed · 视觉模型调用失败',
detail: null,
updatedAt: 26,
}),
],
});
const progress = buildGameChatProgressEvidence(
runtime,
{
'code-prototype': gameChatMainRuntimeWithEvents(
runtime,
runtime.recentEvents ?? [],
),
},
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,
{
'code-prototype': gameChatMainRuntimeWithEvents(
runtime,
runtime.recentEvents ?? [],
),
},
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.validate:ok · 浏览器验证已完成',
detail: JSON.stringify({
diagnostics: [],
diagnosticsCount: 0,
passed: true,
revision: 8,
}),
updatedAt: 26,
}),
],
});
const progress = buildGameChatProgressEvidence(
runtime,
{
'code-prototype': gameChatMainRuntimeWithEvents(
runtime,
runtime.recentEvents ?? [],
),
},
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.validate:ok · 浏览器验证已完成',
detail: null,
updatedAt: 27,
}),
],
});
const progress = buildGameChatProgressEvidence(
runtime,
{
'code-prototype': gameChatMainRuntimeWithEvents(
runtime,
runtime.recentEvents ?? [],
),
},
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 = gameChatPlayableMainRuntime({
parentRunId: runId,
revision: 1,
updatedAt: 3000,
});
const invalidEvidence = [
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
summary: 'preview.validate:failed',
detail: JSON.stringify({
passed: true,
playtestPassed: true,
revision: 8,
}),
updatedAt: 8000,
}),
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
summary: 'preview.validate:ok · 验证未通过',
detail: JSON.stringify({
passed: false,
playtestPassed: false,
revision: 7,
}),
updatedAt: 7000,
}),
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
summary: 'preview.validate:ok · 缺少 revision',
detail: JSON.stringify({ passed: true, playtestPassed: true }),
updatedAt: 6000,
}),
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
summary: 'preview.validate:ok · revision 非正整数',
detail: JSON.stringify({
passed: true,
playtestPassed: true,
revision: 0,
}),
updatedAt: 5000,
}),
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: invalidPlaytest.sessionId,
runId: invalidPlaytest.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
summary: 'preview.validate:ok · revision 不是整数',
detail: JSON.stringify({
passed: true,
playtestPassed: true,
revision: 1.5,
}),
updatedAt: 4000,
}),
];
invalidPlaytest.recentEvents = invalidEvidence;
const runtimeWithOnlyInvalidEvidence = gameChatRuntimeState({
runId,
});
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'code-prototype': invalidPlaytest,
}),
).toBeNull();
const previewPlaytest = gameChatPlayableMainRuntime({
parentRunId: runId,
revision: 9,
updatedAt: 8000,
});
previewPlaytest.recentEvents?.push(
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: previewPlaytest.sessionId,
runId: previewPlaytest.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
status: 'completed',
phase: 'tool-observation',
summary: 'preview.validate:ok · revision 10 首次验证',
detail: JSON.stringify({
diagnosticsCount: 0,
passed: true,
playtestPassed: true,
revision: 10,
}),
updatedAt: 8500,
}),
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: previewPlaytest.sessionId,
runId: previewPlaytest.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
status: 'completed',
phase: 'tool-observation',
summary: 'preview.validate:ok · revision 10 最新验证',
detail: JSON.stringify({
diagnosticsCount: 0,
passed: true,
playtestPassed: true,
revision: 10,
}),
updatedAt: 8800,
}),
);
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'code-prototype': previewPlaytest,
}),
).toEqual(
expect.objectContaining({ runId, revision: 10, validatedAt: 8800 }),
);
const staleParentPlaytest = gameChatPlayableMainRuntime({
parentRunId: 'different-parent-run',
revision: 11,
updatedAt: 11000,
});
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'code-prototype': staleParentPlaytest,
}),
).toBeNull();
const wrongSourcePlaytest = {
...gameChatPlayableMainRuntime({
parentRunId: runId,
revision: 12,
updatedAt: 12000,
}),
source: 'agent-delegate' as const,
};
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'code-prototype': wrongSourcePlaytest,
}),
).toBeNull();
const higherFailedRevision = gameChatPlayableMainRuntime({
parentRunId: runId,
revision: 20,
updatedAt: 20000,
});
higherFailedRevision.recentEvents?.push(
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: higherFailedRevision.sessionId,
runId: higherFailedRevision.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
status: 'failed',
phase: 'tool-observation',
summary: 'preview.validate:failed · revision 21',
detail: JSON.stringify({
passed: false,
playtestPassed: false,
revision: 21,
}),
updatedAt: 21000,
}),
);
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'code-prototype': higherFailedRevision,
}),
).toBeNull();
const sameRevisionLaterFailed = gameChatPlayableMainRuntime({
parentRunId: runId,
revision: 22,
updatedAt: 22000,
});
sameRevisionLaterFailed.recentEvents?.push(
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: sameRevisionLaterFailed.sessionId,
runId: sameRevisionLaterFailed.runId,
source: 'agent-ready-task-scheduler',
eventType: 'observation',
status: 'failed',
phase: 'tool-observation',
summary: 'preview.validate:failed · revision 22 后续回归',
detail: JSON.stringify({
passed: false,
playtestPassed: false,
revision: 22,
}),
updatedAt: 22100,
}),
);
expect(
latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, {
'code-prototype': 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-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: now - 5 * 60 * 1000 - 10 * 1000,
recentEvents: [
gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
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-prototype': gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
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-delegate',
parentAgentId: 'code-prototype',
parentRunId: 'game-chat-stalled-code-run',
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',
source: 'agent-ready-task-scheduler',
eventType: 'turn.completed',
status: 'completed',
phase: 'completed',
summary: '子任务晚于父 Run 收口',
updatedAt: now - 30 * 1000,
}),
],
});
renderGameChatStatus({
runtime,
runtimeByAgentId: { 'code-prototype': 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('fails closed when the game-chat surface hydrates a non-game-chat Supervisor root', () => {
const guiRuntime = gameChatRuntimeState({
source: 'project-supervisor-gui',
recentEvents: [
gameChatRuntimeEvent({
summary: '不应进入 game-chat 当前投影',
updatedAt: 100,
}),
],
});
renderGameChatStatus({ runtime: guiRuntime });
expect(screen.getByLabelText('最新状态').textContent).toContain('未运行');
expect(screen.queryByText('不应进入 game-chat 当前投影')).toBeNull();
expect(buildGameChatProgressEvidence(guiRuntime, {}, null)).toBeNull();
expect(collectGameChatRuntimeEvents(guiRuntime, {})).toEqual([]);
});
it('keeps game-chat running while an exact descendant is still active after root terminal projection', () => {
const root = gameChatRuntimeState({
runId: 'terminal-root-active-main',
status: 'completed',
phase: 'completed',
updatedAt: 200,
});
const main = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'terminal-root-active-main-session',
runId: 'terminal-root-active-main-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
status: 'running',
phase: 'waiting-for-delegate-receipts',
currentAction: '等待动态美术回执',
updatedAt: 201,
});
renderGameChatStatus({
runtime: root,
runtimeByAgentId: { 'code-prototype': main },
});
const status = screen.getByLabelText('最新状态').textContent ?? '';
expect(status).toContain('运行中');
expect(status).toContain('主阶段 0/1');
expect(status).toContain('正在等待专业 Agent 回执');
expect(status).not.toContain('本轮已完成');
});
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 mainRuntime = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'game-chat-mud-point-main-session',
runId: 'game-chat-mud-point-main-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: rootRunId,
status: 'failed',
phase: 'failed',
updatedAt: 9050,
});
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: 'code-prototype',
parentRunId: mainRuntime.runId,
status: 'failed',
phase: 'failed',
error:
'平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2;operationId=private-operation-id',
updatedAt: 9000,
});
const runtimeByAgentId = {
'code-prototype': mainRuntime,
'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('archives a safe actionable Runtime failure instead of a generic failed stage', () => {
const runtime = gameChatRuntimeState({
runId: 'game-chat-context-failure-run',
status: 'failed',
phase: 'failed',
error:
'kind=codex-app-server-context-window-exceeded fingerprint=' +
'a'.repeat(64),
updatedAt: 9200,
});
const progress = buildGameChatProgressEvidence(runtime, {}, null);
if (!progress) {
throw new Error('missing context failure progress fixture');
}
const stageRecord = formatGameChatStageRecord(runtime, progress, []);
expect(stageRecord).toContain(
'项目总控 Agent 模型上下文已超限,请缩小任务范围后重试',
);
expect(stageRecord).not.toContain('fingerprint');
expect(stageRecord).not.toContain('本轮失败');
});
it('counts only the current single main stage in game-chat progress', () => {
const manifest = createGameCreationAppManifest(
'game-chat-progress-total',
'game-chat-progress-total',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
const runtime = gameChatRuntimeState({
runId: 'game-chat-progress-total-run',
status: 'running',
phase: 'execution',
updatedAt: 9000,
});
const mainRuntime = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'game-chat-progress-main-session',
runId: 'game-chat-progress-main-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: runtime.runId,
status: 'completed',
phase: 'completed',
updatedAt: 8999,
});
renderGameChatStatus({
runtime,
runtimeByAgentId: { 'code-prototype': mainRuntime },
manifest,
});
const status = screen.getByLabelText('最新状态');
expect(status.textContent).toContain('主阶段 1/1');
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('主阶段 1/1');
});
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({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'code-prototype-progress-session',
runId: 'code-prototype-progress-run',
source: 'agent-ready-task-scheduler',
eventType: 'observation',
status: 'failed',
phase: 'tool-observation',
summary: 'preview.validate:failed',
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: [],
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,
recentEvents: [previewFailure],
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('主阶段 0/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('主阶段 0/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({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: codePrototypeRuntime.sessionId,
runId: codePrototypeRuntime.runId,
source: 'agent-ready-task-scheduler',
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,
},
},
});
const updatedMainRuntime = {
...codePrototypeRuntime,
recentEvents: [previewFailure, delegateDecision],
updatedAt: 7501,
};
professionalRuntimes = [updatedMainRuntime];
harness.emitAgentRuntime(updatedMainRuntime);
});
await waitFor(() => {
expect(within(progress).getByText('本轮生成进度')).not.toBeNull();
expect(within(progress).queryByText(/第 5 轮/u)).toBeNull();
expect(
within(progress).getByText('主阶段 0/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('freezes a terminal game-chat stage before a delayed conversation refresh and fast next turn', async () => {
const projectPath = '/tmp/game-chat-stage-record-fast-next-turn';
let professionalRuntimes: AgentRuntimeState[] = [];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => professionalRuntimes,
});
const manifest = createGameCreationAppManifest(
'game-chat-stage-record-fast-next-turn',
'game-chat-stage-record-fast-next-turn',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
const conversationGate = createDeferred<void>();
const manifestGate = createDeferred<void>();
let delayConversationRefresh = false;
let delayManifestCapture = false;
let delayedConversationReads = 0;
let delayedManifestReads = 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-stage-record-fast-next-turn',
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') {
if (delayManifestCapture) {
delayedManifestReads += 1;
await manifestGate.promise;
}
return manifest;
}
if (command === 'read_local_conversation' && delayConversationRefresh) {
delayedConversationReads += 1;
await conversationGate.promise;
}
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(
'项目总控对话内容',
) as HTMLTextAreaElement;
await waitFor(() => expect(composer.disabled).toBe(false));
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(1);
});
const firstStart = invoke.mock.calls.find(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
);
const firstRunId = String(firstStart?.[1]?.runId ?? '');
const main = gameChatPlayableMainRuntime({
parentRunId: firstRunId,
revision: 17,
updatedAt: 1700,
});
professionalRuntimes = [main];
act(() => harness.emitAgentRuntime(main));
delayConversationRefresh = true;
delayManifestCapture = true;
act(() => {
harness.emitRuntime(
harness.runtimeState({
runId: firstRunId,
source: 'project-supervisor-game-chat',
status: 'completed',
phase: 'completed',
updatedAt: 4000,
}),
);
});
await waitFor(() => {
expect(delayedConversationReads).toBeGreaterThan(0);
expect(delayedManifestReads).toBeGreaterThan(0);
});
fireEvent.change(composer, { target: { value: '立即开始下一轮' } });
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => expect(delayedManifestReads).toBeGreaterThan(1));
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(1);
manifestGate.resolve(undefined);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(2);
});
conversationGate.resolve(undefined);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
String(
(args?.message as { content?: string } | undefined)?.content ??
'',
).startsWith('【Supervisor 阶段记录】'),
),
).toHaveLength(1);
});
});
it('persists one terminal game-chat stage record and keeps it in the next round', async () => {
const projectPath = '/tmp/game-chat-stage-record';
let professionalRuntimes: AgentRuntimeState[] = [];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => professionalRuntimes,
});
const manifest = createGameCreationAppManifest(
'game-chat-stage-record',
'game-chat-stage-record',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...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 mainRuntime = gameChatPlayableMainRuntime({
parentRunId: firstRunId,
revision: 9,
updatedAt: 9100,
});
const rework = gameChatRuntimeEvent({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: mainRuntime.sessionId,
runId: mainRuntime.runId,
source: 'agent-ready-task-scheduler',
eventType: 'action',
summary: '调用工具 agent.delegate',
detail: '根据上一版试玩诊断安排程序 Agent 完成返工',
updatedAt: 9000,
});
mainRuntime.recentEvents = [rework, ...(mainRuntime.recentEvents ?? [])];
professionalRuntimes = [mainRuntime];
const terminalRuntime = harness.runtimeState({
runId: firstRunId,
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
status: 'idle',
phase: 'completed',
loopIteration: 6,
currentAction: '等待下一轮输入',
planSteps: [
{ index: 0, title: '完成返工', status: 'completed' },
{ index: 1, title: '通过试玩', status: 'completed' },
],
activePlanStepIndex: null,
recentEvents: [],
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(() => {
harness.emitAgentRuntime(mainRuntime);
updateHandler({
payload: {
projectPath,
agentId: 'project-supervisor',
runId: firstRunId,
status: 'idle',
phase: 'completed',
runtime: {
...harness.runtimeResult(terminalRuntime),
recentEvents: [],
},
},
});
});
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';
let professionalRuntimes: AgentRuntimeState[] = [];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => professionalRuntimes,
});
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 === 'code-prototype'
? { ...task, status: 'failed' 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('');
const failedMain = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'manifest-race-main-session',
runId: 'manifest-race-main-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: runId,
status: 'failed',
phase: 'failed',
updatedAt: 9199,
});
professionalRuntimes = [failedMain];
act(() => {
harness.emitAgentRuntime(failedMain);
harness.emitRuntime(
harness.runtimeState({
runId,
source: 'project-supervisor-game-chat',
status: 'failed',
phase: 'failed',
currentAction: 'code-prototype 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('主阶段失败');
});
it('archives a terminal game-chat run restored during initial hydration exactly once', async () => {
const projectPath = '/tmp/game-chat-stage-record-initial-terminal';
const runId = 'game-chat-stage-record-initial-terminal-run';
const mainRuntime = gameChatPlayableMainRuntime({
parentRunId: runId,
revision: 11,
updatedAt: 9100,
});
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => [mainRuntime],
});
const manifest = createGameCreationAppManifest(
'game-chat-stage-record-initial-terminal',
'game-chat-stage-record-initial-terminal',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
const terminalRuntime = harness.runtimeState({
runId,
source: 'project-supervisor-game-chat',
status: 'completed',
phase: 'completed',
currentAction: 'preview complete',
recentEvents: [],
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 mainRuntime = gameChatPlayableMainRuntime({
parentRunId: runId,
revision: 12,
updatedAt: 9100,
});
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) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
const progress = buildGameChatProgressEvidence(
terminalRuntime,
{ 'code-prototype': mainRuntime },
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,
runtimeMapLoader: async () => [mainRuntime],
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,
source: 'project-supervisor-game-chat',
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 = gameChatPlayableMainRuntime({
parentRunId: acceptedRunId,
revision: 1,
updatedAt: 3900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunId,
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
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,
source: 'project-supervisor-game-chat',
status: 'completed',
phase: 'completed',
updatedAt: 9200,
},
runtimeMapLoader: async () => [
gameChatPlayableMainRuntime({
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 = gameChatPlayableMainRuntime({
parentRunId: acceptedRunIds[0],
revision: 1,
updatedAt: 3900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunIds[0],
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
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',
source: 'project-supervisor-game-chat',
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 = gameChatPlayableMainRuntime({
parentRunId: acceptedRunIds[1],
revision: 2,
updatedAt: 7900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunIds[1],
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
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(
gameChatPlayableMainRuntime({
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 = gameChatPlayableMainRuntime({
parentRunId: acceptedRunId,
revision: 3,
updatedAt: 3900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunId,
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
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',
source: 'project-supervisor-game-chat',
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',
source: 'project-supervisor-game-chat',
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(
gameChatPlayableMainRuntime({
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 = gameChatPlayableMainRuntime({
parentRunId: acceptedRunId,
revision: 5,
updatedAt: 3900,
});
await act(async () => {
harness.emitRuntime(
harness.runtimeState({
runId: acceptedRunId,
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
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 without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => {
const projectPath = '/tmp/launcher-supervisor-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-supervisor-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
let directTurnUpdateHandler:
| ((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-direct-turn-update') {
directTurnUpdateHandler = handler;
return () => {
if (directTurnUpdateHandler === handler) {
directTurnUpdateHandler = null;
}
};
}
return supervisorHarness.listen(
eventName,
handler as Parameters<typeof supervisorHarness.listen>[1],
);
},
);
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;
}
if (command === 'chat_with_game_creator_direct_codex') {
return `DIRECT_REPLY:${String(args?.prompt ?? '')}`;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(
within(supervisorSurface).queryByText('已恢复的项目总控历史'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'list_game_creator_agent_sessions',
expect.anything(),
);
expect(
invoke.mock.calls.some(
([command, args]) =>
command === 'read_local_conversation' &&
(args as Record<string, unknown> | undefined)?.agentId ===
'project-supervisor',
),
).toBe(false);
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
expect(screen.queryByRole('status', { name: '自动执行' })).toBeNull();
expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull();
expect(screen.queryByText('严格审批')).toBeNull();
expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull();
expect(screen.queryByLabelText('选择 Agent')).toBeNull();
expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull();
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
'game-creator-agent-runtime-update',
expect.any(Function),
);
const policyReadCountBeforeChat = invoke.mock.calls.filter(
([command]) => command === 'read_project_permission_policy',
).length;
const firstDirectReply = createDeferred<string>();
const secondDirectReply = createDeferred<string>();
let directReplyCount = 0;
invoke.mockImplementation(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'chat_with_game_creator_direct_codex') {
directReplyCount += 1;
return directReplyCount === 1
? firstDirectReply.promise
: secondDirectReply.promise;
}
return supervisorHarness.invoke(command, args);
},
);
fireEvent.change(screen.getByLabelText('陶泥儿对话内容'), {
target: { value: '先完成正式客户端玩法拆解' },
});
fireEvent.click(
within(supervisorSurface).getByRole('button', { name: '发送' }),
);
await waitFor(() =>
expect(
within(
within(supervisorSurface).getByLabelText('陶泥儿执行过程'),
).getByText('已发送消息,正在等待陶泥儿回复'),
).not.toBeNull(),
);
const directMessageList =
within(supervisorSurface).getByLabelText('陶泥儿消息');
Object.defineProperties(directMessageList, {
clientHeight: { configurable: true, value: 180 },
scrollHeight: { configurable: true, value: 640 },
scrollTop: { configurable: true, value: 0, writable: true },
});
const waitingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(waitingProcessCard.parentElement).toBe(directMessageList);
expect(
within(waitingProcessCard).getByText('已发送消息,正在等待陶泥儿回复'),
).not.toBeNull();
expect(
within(waitingProcessCard).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
const firstDirectCall = invoke.mock.calls.find(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
const firstTurnId = String(
(firstDirectCall?.[1] as Record<string, unknown> | undefined)
?.clientTurnId ?? '',
);
expect(firstTurnId).not.toBe('');
expect(firstDirectCall?.[1]).toEqual({
projectPath,
prompt: '先完成正式客户端玩法拆解',
clientTurnId: firstTurnId,
});
await act(async () => {
supervisorHarness.emitProgress('direct', '正在准备安全阶段');
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 0,
status: 'accepted',
activity: 'understanding',
accumulatedText: 'DIRECT_STREAM:先完成',
updatedAt: 1000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 2,
status: 'streaming',
activity: 'controlled-tool',
accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解',
updatedAt: 2000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 1,
status: 'streaming',
activity: 'file-change',
accumulatedText: '乱序事件不能回退正文',
updatedAt: 1500,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: 'old-direct-turn',
sequence: 99,
status: 'streaming',
activity: 'validation',
accumulatedText: '旧回合不能覆盖正文',
updatedAt: 3000,
},
});
directTurnUpdateHandler?.({
payload: {
projectPath: '/tmp/wrong-direct-project',
turnId: firstTurnId,
sequence: 100,
status: 'streaming',
activity: 'response-finalization',
accumulatedText: '错误项目不能覆盖正文',
updatedAt: 4000,
},
});
supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件');
});
const streamingProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
within(streamingProcessCard).getByText('正在执行受控工具'),
).not.toBeNull();
expect(
within(streamingProcessCard).getByLabelText('陶泥儿实时回复').textContent,
).toContain('DIRECT_STREAM:先完成正式客户端玩法拆解');
expect(directMessageList.scrollTop).toBe(640);
expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull();
expect(
within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'),
).toBeNull();
directMessageList.scrollTop = 100;
fireEvent.scroll(directMessageList);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: firstTurnId,
sequence: 3,
status: 'streaming',
activity: 'validation',
accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解',
updatedAt: 4500,
},
});
});
expect(directMessageList.scrollTop).toBe(100);
expect(
within(streamingProcessCard).getByText('正在验证结果'),
).not.toBeNull();
await act(async () => {
firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解');
});
await waitFor(() => {
expect(
within(supervisorSurface).getAllByText(
'DIRECT_REPLY:先完成正式客户端玩法拆解',
),
).toHaveLength(1);
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
expect(
within(supervisorSurface).queryByText(
'DIRECT_STREAM:先完成正式客户端玩法拆解',
),
).toBeNull();
expect(
within(directMessageList).queryByLabelText('陶泥儿执行过程'),
).toBeNull();
});
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(
'chat_with_game_creator_direct_codex',
{
projectPath,
prompt: '补充:优先复用现有素材',
clientTurnId: expect.any(String),
},
);
});
const directCalls = invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
);
const secondTurnId = String(
(directCalls[1]?.[1] as Record<string, unknown> | undefined)
?.clientTurnId ?? '',
);
expect(secondTurnId).not.toBe(firstTurnId);
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: secondTurnId,
sequence: 0,
status: 'streaming',
activity: 'not-a-public-activity',
accumulatedText: '即将失败的临时正文',
updatedAt: 5000,
},
});
});
const failedTurnProcessCard =
within(directMessageList).getByLabelText('陶泥儿执行过程');
expect(
within(failedTurnProcessCard).getByText('陶泥儿正在处理'),
).not.toBeNull();
expect(
within(failedTurnProcessCard).getByLabelText('陶泥儿实时回复')
.textContent,
).toContain('即将失败的临时正文');
await act(async () => {
directTurnUpdateHandler?.({
payload: {
projectPath,
turnId: secondTurnId,
sequence: 1,
status: 'failed',
activity: 'none',
accumulatedText: '失败事件不得保留这段正文',
updatedAt: 5100,
},
});
});
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
expect(
within(directMessageList).getByLabelText('陶泥儿执行过程'),
).not.toBeNull();
expect(
within(supervisorSurface).getByText('处理失败,正在同步错误'),
).not.toBeNull();
await act(async () => {
secondDirectReply.reject(new Error('模拟 direct 失败'));
});
await waitFor(() => {
expect(
within(supervisorSurface).queryByLabelText('陶泥儿实时回复'),
).toBeNull();
expect(
within(directMessageList).queryByLabelText('陶泥儿执行过程'),
).toBeNull();
expect(
(
within(supervisorSurface).getByRole('button', {
name: '发送',
}) as HTMLButtonElement
).disabled,
).toBe(false);
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'steer_game_creator_agent_runtime_task',
),
).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toHaveLength(2);
expect(
invoke.mock.calls.filter(
([command]) => command === 'get_local_game_preview_status',
),
).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_local_game_preview',
),
).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_project_permission_policy',
),
).toHaveLength(policyReadCountBeforeChat + 1);
});
it('does not poll or render legacy professional Agent runtime state in direct product chat', async () => {
const projectPath = '/tmp/launcher-runtime-status-game';
let professionalRuntimeReadCount = 0;
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-runtime-status-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => {
professionalRuntimeReadCount += 1;
return [];
},
});
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;
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
expect(await screen.findByLabelText('陶泥儿项目对话')).not.toBeNull();
await act(async () => {
await Promise.resolve();
});
expect(professionalRuntimeReadCount).toBe(0);
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
expect.anything(),
);
expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull();
expect(screen.queryByLabelText('专业 Agent 实时状态')).toBeNull();
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull();
});
it('does not hydrate a ready legacy Supervisor stream into direct product chat', 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');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
expect(within(supervisorSurface).queryByText(reply)).toBeNull();
expect(
within(supervisorSurface).queryByLabelText('项目总控 Agent 实时回复'),
).toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
expect.anything(),
);
expect(supervisorHarness.listen).not.toHaveBeenCalledWith(
'game-creator-agent-runtime-update',
expect.any(Function),
);
});
it('rejects picker project paths with control characters before project calls', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'pick_local_project_directory') {
return '/tmp/bad\u0007path';
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '新建项目' }));
expect(await screen.findByText('项目目录不能包含控制字符')).not.toBeNull();
expect(invoke).not.toHaveBeenCalledWith(
'is_local_project_directory_non_empty',
expect.anything(),
);
});
it('opens a selected Godot project as the active root and keeps direct Codex composer keyboard semantics', 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,
godotProjectRoot: 'game-source',
projectName: null,
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'import_local_godot_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'chat_with_game_creator_direct_codex') {
return 'DIRECT_GODOT_OK';
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'start_local_game_preview') {
return {
url: 'http://127.0.0.1:43125/game/index.html',
port: 43125,
root: projectPath,
};
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
fireEvent.click(screen.getByRole('button', { name: '打开项目' }));
const surface = await screen.findByLabelText('陶泥儿项目对话');
expect(invoke).toHaveBeenCalledWith('import_local_godot_project', {
projectPath,
projectId: expect.stringMatching(/^local-project-/),
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: '修改玩家移动脚本' } });
expect(fireEvent.keyDown(composer, { key: 'Enter', shiftKey: true })).toBe(
true,
);
expect(
invoke.mock.calls.some(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toBe(false);
expect(
fireEvent.keyDown(composer, { key: 'Enter', isComposing: true }),
).toBe(true);
expect(
invoke.mock.calls.some(
([command]) => command === 'chat_with_game_creator_direct_codex',
),
).toBe(false);
expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
{
projectPath,
prompt: '修改玩家移动脚本',
clientTurnId: expect.any(String),
},
);
});
expect(invoke).not.toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
expect.anything(),
);
});
}
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(
'Runtime:running / 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(
'Runtime:idle / 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('Runtime:running / 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(),
);
});
}