Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/harness.ts
T
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +08:00

1580 lines
50 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 { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as clientApi from '../../src/services/clientApi';
import { resetLlmModelCatalogCacheForTest } from '../../src/services/llmModelCatalog';
import { useLauncherHomeDraftStore } from '../../src/view/home/useHomeDraftStore';
const nativeClipboardMock = vi.hoisted(() => ({
text: '',
}));
const TEST_LOCAL_PROJECT_PATH = '/tmp/genarrative-ai-game-draft';
const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'createObjectURL',
);
const originalRevokeObjectUrl = Object.getOwnPropertyDescriptor(
URL,
'revokeObjectURL',
);
const originalPointerEvent = Object.getOwnPropertyDescriptor(
window,
'PointerEvent',
);
const originalClipboardEvent = Object.getOwnPropertyDescriptor(
window,
'ClipboardEvent',
);
const originalDragEvent = Object.getOwnPropertyDescriptor(window, 'DragEvent');
const originalRangeGetBoundingClientRect = Object.getOwnPropertyDescriptor(
Range.prototype,
'getBoundingClientRect',
);
const originalIntersectionObserver = Object.getOwnPropertyDescriptor(
window,
'IntersectionObserver',
);
const originalResizeObserver = Object.getOwnPropertyDescriptor(
window,
'ResizeObserver',
);
class TestPointerEvent extends MouseEvent {
readonly pointerId: number;
constructor(
type: string,
init: MouseEventInit & { pointerId?: number } = {},
) {
super(type, init);
this.pointerId = init.pointerId ?? 0;
}
}
// jsdom 没有实现 DragEvent / ClipboardEvent,而 Lexical 在粘贴与拖放处理里用
// `instanceof DragEvent` / `instanceof ClipboardEvent` 判定事件来源,缺失会直接抛
// ReferenceError,粘贴通路根本进不去编辑器。这里只补最小的构造器,让事件能带上
// `clipboardData` 并被 Lexical 认出来,不模拟真实浏览器语义。
class TestClipboardEvent extends Event {}
class TestDragEvent extends MouseEvent {}
// jsdom 的 Range 也没有 getBoundingClientRect,Lexical 计算选区矩形时会抛 TypeError。
function emptyRangeRect(): DOMRect {
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
toJSON: () => ({}),
} as DOMRect;
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key) {
return values.get(key) ?? null;
},
key(index) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key) {
values.delete(key);
},
setItem(key, value) {
values.set(key, String(value));
},
};
}
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: createMemoryStorage(),
});
vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
readImage: vi.fn(async () => {
throw new Error('no native clipboard image');
}),
readText: vi.fn(async () => nativeClipboardMock.text),
}));
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import {
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
type GameCreationAgentRunTrace,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import {
App,
AuthenticatedClient,
deriveAgentStatusCards,
WorkspaceLauncher,
} from '../../src/App';
import { projectNameFromPath } from '../../src/features/agent-runtime/model';
import ProjectDevelopmentView from '../../src/view/project-development';
const testAuthUser: AuthUser = {
id: 'user-test',
publicUserCode: 'tn-test',
displayName: '测试用户',
avatarUrl: null,
phoneNumber: null,
phoneNumberMasked: '138****0000',
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
wechatDisplayName: null,
wechatAccount: null,
};
function renderAppAt(path: string) {
window.history.pushState({}, '', path);
render(React.createElement(App));
const params = new URLSearchParams(window.location.search);
if (params.has('dev') && !params.has('projectPath')) {
const projectPathInput = screen.queryByLabelText('本地项目目录');
if (projectPathInput) {
fireEvent.change(projectPathInput, {
target: { value: TEST_LOCAL_PROJECT_PATH },
});
}
}
}
function renderLauncherAt(
path: string,
initialView: 'home' = 'home',
strictMode = false,
) {
window.history.pushState({}, '', path);
const launcher = React.createElement(WorkspaceLauncher, {
currentUser: testAuthUser,
initialView,
onLogout: vi.fn(),
});
render(
strictMode
? React.createElement(React.StrictMode, null, launcher)
: launcher,
);
}
function renderLauncherProjectsAt(path: string) {
renderLauncherAt(path);
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
}
function pickProjectFromLauncher(
projectPath: string,
action: '打开项目' | '新建项目' = '打开项目',
) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
throw new Error('Tauri invoke is not configured');
}
window.__TAURI__ = {
...window.__TAURI__,
core: {
...window.__TAURI__.core,
invoke: async (command, args) =>
command === 'pick_local_project_directory'
? projectPath
: invoke(command, args),
},
};
fireEvent.click(screen.getByRole('button', { name: action }));
}
function isNativeTextControl(
element: HTMLElement,
): element is HTMLInputElement | HTMLTextAreaElement {
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA';
}
// Lexical 的编辑器状态提交排在微任务里(外部 value 变化、粘贴、删除都走这条路径),
// 读取或提交前先让 React 追平编辑器内容,否则会读到上一帧的文本/禁用状态。
// 这里让出一个宏任务而不是用 act(async),因为 act 在编辑器持续自更新的用例里会长时间不收敛。
async function settleComposer() {
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
// 读取输入区文本:原生 input/textarea 读 value,Lexical contenteditable 读 textContent。
async function composerValue(element: HTMLElement) {
await settleComposer();
return isNativeTextControl(element)
? element.value
: (element.textContent ?? '');
}
type ComposerScope = { getByLabelText: (label: string) => HTMLElement };
async function composerText(label = '创作想法', scope: ComposerScope = screen) {
return composerValue(scope.getByLabelText(label));
}
async function composerDisabled(
label = '创作想法',
scope: ComposerScope = screen,
) {
await settleComposer();
const element = scope.getByLabelText(label);
if (isNativeTextControl(element)) {
return element.disabled;
}
const wrapper = element.closest('.resource-reference-input');
return (
wrapper?.getAttribute('data-disabled') === 'true' ||
element.getAttribute('contenteditable') !== 'true'
);
}
// 把整段内容选进 DOM 选区,并让 Lexical 吸收它:jsdom 不会为程序化选区派发
// selectionchange,编辑器内部 selection 不更新的话,粘贴会插在旧内容旁边而不是覆盖。
function selectComposerContents(element: HTMLElement) {
element.focus();
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
}
function clearComposerContents(element: HTMLElement) {
fireEvent.keyDown(element, { key: 'Delete', code: 'Delete' });
}
// 写完内容后把光标收回到末尾,模拟真实输入后的选区;否则残留的“全选”会改变
// Shift+Enter 等按键的后续语义。
function collapseComposerSelectionToEnd(element: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
document.dispatchEvent(new Event('selectionchange'));
}
function pasteComposerText(element: HTMLElement, value: string) {
fireEvent.paste(element, {
clipboardData: {
getData: (type: string) => (type === 'text/plain' ? value : ''),
files: [],
items: [],
types: ['text/plain'],
},
});
}
// 向输入区写入文本,保证写入结果等于 value。原生控件用 change 事件;Lexical
// contenteditable 先全选、清空,再走产品真实的 paste 通路写入。
async function setComposerText(element: HTMLElement, value: string) {
if (isNativeTextControl(element)) {
fireEvent.change(element, { target: { value } });
return;
}
selectComposerContents(element);
await settleComposer();
clearComposerContents(element);
await settleComposer();
if (value.length > 0) {
pasteComposerText(element, value);
await settleComposer();
}
collapseComposerSelectionToEnd(element);
await settleComposer();
}
async function submitChat(value: string) {
await setComposerText(screen.getByLabelText('创作想法'), value);
fireEvent.click(screen.getByRole('button', { name: '发送' }));
}
// 同一张资源卡上的按钮变少了:卡片本体只有「选中资源」与媒体播放钮两个(原先右上角
// 那个 @ 引用圆钮已挪进选中工具条)。选中按钮的可访问名模板固定为
// `选中资源:<分类标签> <资源文件名>`;分类标签可能自带空格(例如 `UI 交互`),
// 这里按模板做精确匹配。
function escapeRegExpLiteral(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function resourceSelectButtonName(label: string) {
return new RegExp(`^选中资源:.+ ${escapeRegExpLiteral(label)}$`);
}
function getResourceSelectButton(label: string) {
return screen.getByRole('button', { name: resourceSelectButtonName(label) });
}
function findResourceSelectButton(
label: string,
options?: { timeout?: number; interval?: number },
) {
return screen.findByRole(
'button',
{ name: resourceSelectButtonName(label) },
options,
);
}
function queryResourceSelectButton(label: string) {
return screen.queryByRole('button', {
name: resourceSelectButtonName(label),
});
}
/**
* 叫出画布唯一的筛选面板(右下角 Dock 的放大镜按钮)并返回关键词输入框。
*
* 面板只在打开时存在于 DOM:用例必须走产品入口打开它,`getByLabelText` 在面板没打开时
* 会直接抛错,所以「输入框在真实 UI 里点不到」那类假绿不会再出现。关键词 / 所在区域 /
* 自定义标签三个字段都在这一次打开的面板里(`Ctrl/Cmd+F` 叫出的是同一个)。
*/
function openResourceFilterPanel() {
fireEvent.click(screen.getByRole('button', { name: '搜索资源' }));
return screen.getByLabelText('查找素材') as HTMLInputElement;
}
function emptyProjectPolicy() {
return {
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: [],
},
};
}
const roleAgentMockReply =
'专业 Agent 已完成本轮判断:先补齐角色规范图、验收口径和后续生成条件。';
function mockRoleAgentReply() {
return roleAgentMockReply;
}
function planningResponseStream({
runId,
sequence,
accumulatedText,
status = 'streaming',
appliedSteerCursor = 0,
responseRevision = 0,
loopIteration = 1,
overrides = {},
}: {
runId: string;
sequence: number;
accumulatedText: string;
status?: 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
appliedSteerCursor?: number;
responseRevision?: number;
loopIteration?: number;
overrides?: Record<string, unknown>;
}) {
return {
schemaVersion: 'game-creator-runtime-response-stream.v1',
agentId: 'planning-agent-v2',
taskId: 'planning-agent-v2',
sessionId: 'planning-session-active',
runId,
requestKind: 'final-reply',
requestSlot: `final-reply-loop-${loopIteration}-revision-${responseRevision}`,
appliedSteerCursor,
responseRevision,
sequence,
status,
accumulatedText,
finishReason: status === 'ready' || status === 'committed' ? 'stop' : null,
startedAt: 6000,
updatedAt: 6000 + sequence,
...overrides,
};
}
function agentRuntimeUserInputRequest({
agentId,
sessionId,
runId,
requestId = 'request-user-input-1',
actionId = 'action-user-input-1',
responseId = null,
status = 'pending',
}: {
agentId: string;
sessionId: string;
runId: string;
requestId?: string;
actionId?: string;
responseId?: string | null;
status?: 'pending' | 'answer-prepared';
}) {
return {
schemaVersion: 'game-creator-runtime-user-input.v1',
requestId,
agentId,
taskId: agentId,
sessionId,
runId,
actionId,
status,
questions: [
{
id: 'visual_direction',
header: '美术方向',
question: '首版角色规范图采用哪种美术方向?',
options: [
{
label: '像素风',
description: '优先验证轮廓与动作可读性。',
},
{
label: '手绘风',
description: '优先验证角色气质与细节。',
},
],
},
],
allowFreeform: true,
responseId,
requestedAt: 6000,
updatedAt: 6000,
};
}
function createPlanGddStateView(
overrides: Partial<PlanGddStateViewV1> = {},
): PlanGddStateViewV1 {
const gddRef = {
gddId: 'gdd-plan-0001',
version: 1,
fingerprint: 'sha256-serde-json-v2:1111111111111111',
};
return {
schemaVersion: 'plan-gdd-state-view.v1',
projectId: 'local-project-draft',
gddId: gddRef.gddId,
state: 'ready_for_approval',
session: {
sessionId: 'plan-session-0001',
sessionRevision: 3,
sessionFingerprint: 'sha256-serde-json-v2:2222222222222222',
phase: 'awaiting_gdd_approval',
clarificationRound: 2,
repairDepth: 0,
accumulatedAgentMillis: 42_000,
activeRunId: null,
awaitingAnswerFor: null,
decisionStateCounts: {
confirmed: 2,
defaultPending: 1,
prototypePending: 0,
},
},
versions: [
{
gddRef,
status: 'ready_for_approval',
approvalRequestId: 'gdd-approval-0001',
createdAtUtc: '2026-08-18T00:00:00Z',
decision: null,
},
],
displayGdd: {
schemaVersion: 'plan-gdd.v1',
projectId: 'local-project-draft',
gddId: gddRef.gddId,
version: gddRef.version,
submissionId: 'action-0123456789abcdef01234567',
approvalRequestId: 'gdd-approval-0001',
actionFingerprint: 'a'.repeat(64),
agentId: 'project-planning',
source: 'agent-delegate',
runProfile: 'standard',
runProfileBindingFingerprint: 'b'.repeat(64),
rootAgentId: 'planning-agent-v2',
rootRunId: 'run-plan-root-0001',
delegationId: 'delegation-0001',
sessionId: 'plan-session-0001',
sourceSessionRevision: 2,
sourceSessionFingerprint: 'sha256-serde-json-v2:3333333333333333',
createdByRunId: 'run-plan-child-0001',
createdAtUtc: '2026-08-18T00:00:00Z',
fingerprint: gddRef.fingerprint,
game: {
title: '灯塔守夜人',
oneLiner: '在潮汐涨落之间调度光束,护送迷航的船只回港。',
genre: { primary: '策略', fusion: null },
artStyle: {
visualType: '像素',
keywords: ['夜色', '海雾'],
moodAndColor: '冷蓝为主,暖黄光束作为唯一高光。',
mvpArtBoundary: '只做灯塔与三类船只的静帧。',
},
pillars: [
{
name: '光束调度',
playerFeel: '在有限视野里做取舍。',
mechanism: '每回合只能照亮一个扇区。',
decisionState: 'confirmed',
basis: null,
},
],
coreLoop: ['观察潮汐', '分配光束', '结算返港'],
targetUsers: {
coreUsers: '喜欢短局策略的玩家',
preferences: '偏好可预测的规则',
sessionLength: '单局 5 分钟',
referenceGames: ['灯塔物语'],
},
platformFacts: {
runtime: 'web',
viewports: ['desktop', 'mobile'],
inputs: ['pointer'],
preview: '本地预览',
},
mvpSystems: [
{
system: '潮汐时钟',
minimalFunction: '固定三段潮汐循环。',
whyRequired: '没有它就没有节奏压力。',
verifyMethod: '观察一局内三段是否各触发一次。',
decisionState: 'confirmed',
basis: null,
},
],
outOfScope: ['多人对战'],
creatorTips: {
doFirst: '先做潮汐时钟。',
deferForNow: '暂缓天气系统。',
howToVerify: '单局跑满三段潮汐。',
expandWhen: '核心循环稳定后再加船种。',
},
},
decisions: [
{
id: 'decision-0001',
topic: '光束是否可分裂',
state: 'confirmed',
answerSource: 'user_option',
round: 1,
answerSummary: '不可分裂,保持取舍压力。',
basis: null,
},
],
prototypeValidationItems: [
{
id: 'proto-0001',
question: '单扇区照明是否足够做出取舍?',
microPrototype: '纸面推演三回合。',
observation: '玩家是否出现犹豫。',
passCriterion: '三回合内至少一次改变计划。',
},
],
},
pendingApproval: {
gddRef,
pendingActionId: 'action-0123456789abcdef01234567',
actionFingerprint: 'a'.repeat(64),
approvalRequestId: 'gdd-approval-0001',
sessionId: 'plan-session-0001',
runId: 'run-plan-root-0001',
},
approvedGddRef: null,
recoveryPending: false,
...overrides,
};
}
/**
* 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。
*
* `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。
*/
function directThreadEventItemId(
event: Record<string, unknown>,
): string | null {
if (event.type !== 'item.started' && event.type !== 'item.completed') {
return null;
}
const item = event.item;
if (!item || typeof item !== 'object') return null;
const itemId = (item as Record<string, unknown>).itemId;
return typeof itemId === 'string' && itemId ? itemId : null;
}
function createProjectChatRuntimeHarness({
projectPath = '/tmp/authorized-game',
sessionId = 'planning-session-active',
initialSessionExists = true,
projectMessages = [],
agentMessages = [],
initialRuntime,
initialResponseStream = null,
initialProjectRevision = 0,
runtimeMapLoader,
designAgentView = null,
designAgentContinueView = null,
designWorkspaceFiles = [],
}: {
projectPath?: string;
sessionId?: string;
initialSessionExists?: boolean;
projectMessages?: Array<Record<string, unknown>>;
agentMessages?: Array<Record<string, unknown>>;
initialRuntime?: Record<string, unknown>;
initialResponseStream?: Record<string, unknown> | null;
initialProjectRevision?: number;
runtimeMapLoader?: () => Promise<Array<Record<string, unknown>>>;
designAgentView?: Record<string, unknown> | null;
designAgentContinueView?: Record<string, unknown> | null;
designWorkspaceFiles?: Array<Record<string, unknown>>;
} = {}) {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let messageSequence = 0;
let selectedModelId = 'quality';
let selectedModelIsDefault = false;
let reasoningEffort = 'high';
let steerSequence = 0;
let sessionExists = initialSessionExists;
let currentProjectRevision = initialProjectRevision;
const currentProjectMessages = [...projectMessages];
const currentAgentMessages = [...agentMessages];
const runtimeState = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'planning-agent-v2',
taskId: 'planning-agent-v2',
sessionId,
runId: 'agent-idle',
source: 'planning-agent-v2',
status: 'idle',
phase: 'idle',
currentTask: '',
currentGoal: '',
currentAction: '',
waitingOn: '',
nextStep: '',
plan: [],
observations: [],
allowedTools: [],
pendingToolAction: null,
lastResponse: null,
error: null,
updatedAt: 1000,
...overrides,
});
let currentRuntime = runtimeState(initialRuntime);
let currentResponseStream = initialResponseStream;
let runtimeReader: (() => Promise<Record<string, unknown>>) | null = null;
let confirmRuntime: Record<string, unknown> | null = null;
let rejectRuntime: Record<string, unknown> | null = null;
let answerRuntime: Record<string, unknown> | null = null;
let answerFailuresRemaining = 0;
let currentDesignAgentView = designAgentView;
const currentDesignContinueView = designAgentContinueView;
const currentDesignWorkspaceFiles = [...designWorkspaceFiles];
let runtimeUpdateHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
runId: string;
status: string;
phase: string;
manifestInvalidated: boolean;
runtime: Record<string, unknown>;
};
}) => void)
| null = null;
/**
* `game-creator-manifest-invalidated` 有不止一个订阅方(工作台壳的清单归并与
* DirectProject 聊天自己的 `@` 引用清单),Tauri 会把事件发给每一个监听者,
* 所以夹具按监听器集合广播,不能只留最后一次注册的那个。
*/
type ManifestInvalidatedHandler = (event: {
payload: {
projectPath: string;
agentId: string;
};
}) => void;
const manifestInvalidatedHandlers = new Set<ManifestInvalidatedHandler>();
let progressHandler:
| ((event: {
payload: {
projectPath: string;
stage: string;
message: string;
};
}) => void)
| null = null;
let designAgentUpdateHandler:
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
let directThreadNotifyHandler:
| ((event: { payload: { subscriptionId: string } }) => void)
| null = null;
let directThreadSubscriptionId: string | null = null;
let directThreadSubscriptionSequence = 0;
// 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取,
// 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。
let pendingDirectThreadEvents: Array<Record<string, unknown>> = [];
let directThreadHistoryItems: Array<Record<string, unknown>> = [];
let directThreadLastCompletedItemId: string | null = null;
// 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期
// 锚点一致,重复 `turn.started` 在生产里不会出现。
let directThreadTurnRunning = false;
// bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目"
// 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。
const directThreadActiveItemIds = new Set<string>();
const directThreadPendingEventSeqs = new Map<
Record<string, unknown>,
number
>();
let directThreadEventSequence = 0;
let directThreadLifecycleAnchor: {
seq: number;
event: Record<string, unknown>;
} | null = null;
const conversationRecord = (
role: 'user' | 'assistant',
content: string,
messageId: string,
) => ({
schemaVersion: 'game-creator-conversation.v1',
role,
content,
agentId: 'planning-agent-v2',
messageId,
updatedAt: 2000 + ++messageSequence,
});
const runtimeResult = (
state = currentRuntime,
responseStream = state === currentRuntime ? currentResponseStream : null,
) => ({
state,
sessionPath: `${projectPath}/.agent/runtime/agents/${String(state.agentId)}.json`,
eventPath: `${projectPath}/.agent/runtime/events/${String(state.agentId)}.jsonl`,
taskPath: `${projectPath}/.agent/runtime/tasks/${String(state.agentId)}.jsonl`,
taskQueue: {
total: state.status === 'idle' ? 0 : 1,
pending: state.status === 'pending' ? 1 : 0,
running: state.status === 'running' ? 1 : 0,
waitingForConfirmation:
state.status === 'waiting-for-confirmation' ? 1 : 0,
waitingForUserInput: state.status === 'waiting-for-user-input' ? 1 : 0,
completed: state.status === 'completed' ? 1 : 0,
failed: state.status === 'failed' ? 1 : 0,
latestRunId: String(state.runId),
updatedAt: Number(state.updatedAt),
},
recentEvents: state.recentEvents ?? [],
recentTasks: [],
responseStream,
userInputRequest: state.userInputRequest ?? null,
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'preflight_web_game_creation') return { status: 'ready' };
if (
command === 'read_game_creator_app_config' ||
command === 'select_game_creator_model' ||
command === 'select_game_creator_reasoning_effort'
) {
if (command === 'select_game_creator_model') {
selectedModelId = String(args?.modelId);
selectedModelIsDefault = args?.isDefault === true;
}
if (command === 'select_game_creator_reasoning_effort') {
reasoningEffort = String(args?.effort);
}
return {
path: '/tmp/test-game-creator-config.json',
config: {
schemaVersion: 'game-creator-config.v2',
agentMode: 'codex_app_server',
selectedModelId,
selectedModelIsDefault,
llm: {
apiKey: '',
baseUrl: '',
model: 'quality',
apiKind: 'openai_responses',
reasoningEffort,
stream: true,
webSearchEnabled: true,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 2,
retryBackoffMs: 500,
},
agentLlm: {},
editorApi: { baseUrl: 'https://dev.genarrative.world', apiKey: '' },
},
};
}
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'get_local_game_project_revision') {
return { revision: currentProjectRevision };
}
if (command === 'init_local_game_project') {
const requestedProjectPath = String(args?.projectPath ?? '');
return {
projectPath: requestedProjectPath,
manifestPath: `${requestedProjectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return [runtimeResult()];
}
if (command === 'list_game_creator_agent_sessions') {
return {
path: `${projectPath}/.agent/conversations/agents/planning-agent-v2/sessions.json`,
agentId: 'planning-agent-v2',
activeSessionId: sessionExists ? sessionId : null,
sessions: sessionExists
? [
{
sessionId,
title: '项目总控',
createdAt: 1000,
updatedAt: 2000,
archivedAt: null,
messageCount: currentAgentMessages.length,
legacy: false,
},
]
: [],
};
}
if (command === 'create_game_creator_agent_session') {
sessionExists = true;
return {
path: `${projectPath}/.agent/conversations/agents/planning-agent-v2/sessions.json`,
agentId: 'planning-agent-v2',
activeSessionId: sessionId,
sessions: [
{
sessionId,
title: String(args?.title ?? ''),
createdAt: 1000,
updatedAt: 2000,
archivedAt: null,
messageCount: currentAgentMessages.length,
legacy: false,
},
],
};
}
if (command === 'read_local_conversation') {
if (args?.agentId === null) {
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...currentProjectMessages],
};
}
if (args?.agentId === 'planning-agent-v2') {
if (args?.sessionId !== sessionId) {
throw new Error(`unexpected agent session ${args?.sessionId}`);
}
return {
path: `${projectPath}/.agent/conversations/agents/planning-agent-v2/sessions/${sessionId}.jsonl`,
agentId: 'planning-agent-v2',
sessionId,
messages: [...currentAgentMessages],
};
}
}
if (command === 'append_local_conversation_message') {
if (args?.agentId !== null) {
throw new Error('React must not append agent conversation');
}
const message = args?.message as {
role: 'user' | 'assistant';
content: string;
agentId: null;
updatedAt?: number;
};
currentProjectMessages.push({
schemaVersion: 'game-creator-conversation.v1',
...message,
...(args?.messageId ? { messageId: String(args.messageId) } : {}),
updatedAt:
typeof message.updatedAt === 'number'
? message.updatedAt
: 1500 + ++messageSequence,
});
return {
path: `${projectPath}/.agent/conversations/project.jsonl`,
agentId: null,
sessionId: null,
messages: [...currentProjectMessages],
};
}
if (command === 'read_game_creator_agent_runtime') {
if (
args?.agentId !== 'planning-agent-v2' ||
args?.sessionId !== sessionId
) {
throw new Error('unexpected agent runtime identity');
}
if (runtimeReader) {
return runtimeReader();
}
return runtimeResult();
}
if (command === 'read_game_creator_agent_runtimes') {
const states = runtimeMapLoader ? await runtimeMapLoader() : [];
return states.map((state) => runtimeResult(state));
}
if (command === 'subscribe_direct_project_thread') {
directThreadSubscriptionSequence += 1;
directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`;
const pending = pendingDirectThreadEvents;
pendingDirectThreadEvents = [];
// 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放,
// 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。
const replay = pending
.filter((event) => {
const itemId = directThreadEventItemId(event);
return Boolean(itemId && directThreadActiveItemIds.has(itemId));
})
.map((event) => ({
seq: directThreadPendingEventSeqs.get(event) ?? 0,
event,
}));
for (const event of pending) {
directThreadPendingEventSeqs.delete(event);
}
if (
directThreadLifecycleAnchor &&
!replay.some(
(entry) => entry.event === directThreadLifecycleAnchor?.event,
)
) {
replay.push(directThreadLifecycleAnchor);
}
replay.sort((left, right) => left.seq - right.seq);
return {
subscriptionId: directThreadSubscriptionId,
lastCompletedItemId: directThreadLastCompletedItemId,
events: replay.map((entry) => entry.event),
};
}
if (command === 'consume_direct_project_thread') {
if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) {
throw new Error('SUBSCRIPTION_EXPIRED');
}
const events = pendingDirectThreadEvents;
pendingDirectThreadEvents = [];
return { events };
}
if (command === 'read_direct_project_history_slice') {
// 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条,
// 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算
// `hasMore`,`firstItemId` 是本屏最老一条的 itemId。
const requestedLimit = Number(args?.limit ?? 20);
const limit = Math.min(
Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1),
200,
);
const throughItemId =
typeof args?.throughItemId === 'string' && args.throughItemId
? args.throughItemId
: null;
const beforeItemId =
typeof args?.beforeItemId === 'string' && args.beforeItemId
? args.beforeItemId
: null;
let end = directThreadHistoryItems.length;
if (throughItemId) {
const anchorIndex = directThreadHistoryItems.findIndex(
(item) => String(item.itemId ?? '') === throughItemId,
);
if (anchorIndex < 0) {
throw new Error(
`DirectProject 历史中不存在 item:${throughItemId}`,
);
}
end = anchorIndex + 1;
} else if (beforeItemId) {
const anchorIndex = directThreadHistoryItems.findIndex(
(item) => String(item.itemId ?? '') === beforeItemId,
);
end =
anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length;
}
const start = Math.max(0, end - limit);
const window = directThreadHistoryItems.slice(start, end);
const oldest = window[0];
return {
items: [...window],
hasMore: start > 0,
firstItemId:
oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null,
};
}
if (command === 'steer_game_creator_agent_runtime_task') {
steerSequence += 1;
currentResponseStream = null;
currentAgentMessages.push(
conversationRecord(
'user',
String(args?.instruction ?? ''),
`steer-user-${steerSequence}`,
),
);
return {
runtime: runtimeResult(),
steerId: String(args?.steerId ?? ''),
sequence: steerSequence,
status: 'queued',
providerInterrupted: false,
};
}
if (command === 'confirm_game_creator_agent_runtime_task') {
currentResponseStream = null;
currentRuntime =
confirmRuntime ??
runtimeState({
runId: String(currentRuntime.runId),
status: 'running',
phase: 'planning',
updatedAt: 4000,
});
return runtimeResult();
}
if (command === 'reject_game_creator_agent_runtime_task') {
currentResponseStream = null;
currentRuntime =
rejectRuntime ??
runtimeState({
runId: String(currentRuntime.runId),
status: 'running',
phase: 'planning',
updatedAt: 5000,
});
return runtimeResult();
}
if (command === 'answer_game_creator_agent_runtime_user_input') {
if (answerFailuresRemaining > 0) {
answerFailuresRemaining -= 1;
throw new Error('模拟回答提交中断');
}
const answers = args?.answers as Record<string, string>;
currentAgentMessages.push(
conversationRecord(
'user',
String(answers?.visual_direction ?? ''),
`answer-user-${String(args?.responseId ?? '')}`,
),
);
currentResponseStream = null;
currentRuntime =
answerRuntime ??
runtimeState({
runId: String(currentRuntime.runId),
status: 'running',
phase: 'planning',
currentAction: '根据用户回答继续规划',
userInputRequest: null,
updatedAt: 6100,
});
return runtimeResult();
}
if (command === 'hydrate_design_agent_session') {
return currentDesignAgentView;
}
if (command === 'get_design_agent_runtime_mode') {
return currentDesignAgentView ? { activeRuntime: 'design' } : null;
}
if (command === 'set_design_agent_runtime_mode') {
return { activeRuntime: args?.activeRuntime };
}
if (command === 'continue_design_agent_session') {
const next = currentDesignContinueView ?? currentDesignAgentView;
if (!next) {
throw new Error('DESIGN_AGENT_VIEW_NOT_CONFIGURED');
}
currentDesignAgentView = next;
return next;
}
if (command === 'decide_design_phase') {
if (!currentDesignAgentView) {
throw new Error('DESIGN_AGENT_VIEW_NOT_CONFIGURED');
}
return currentDesignAgentView;
}
if (command === 'list_design_workspace') {
return currentDesignWorkspaceFiles;
}
if (command === 'read_design_workspace_file') {
return '';
}
throw new Error(`unexpected invoke ${command}`);
},
);
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;
}
if (eventName === 'game-creator-manifest-invalidated') {
manifestInvalidatedHandlers.add(
handler as unknown as ManifestInvalidatedHandler,
);
}
if (eventName === 'game-creator-agent-progress') {
progressHandler = handler as unknown as typeof progressHandler;
}
if (eventName === 'design-agent-update') {
designAgentUpdateHandler =
handler as unknown as typeof designAgentUpdateHandler;
}
if (eventName === 'game-creator-direct-thread-notify') {
directThreadNotifyHandler =
handler as unknown as typeof directThreadNotifyHandler;
}
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
}
manifestInvalidatedHandlers.delete(
handler as unknown as ManifestInvalidatedHandler,
);
if (progressHandler === handler) {
progressHandler = null;
}
if (designAgentUpdateHandler === handler) {
designAgentUpdateHandler = null;
}
if (directThreadNotifyHandler === handler) {
directThreadNotifyHandler = null;
}
};
},
);
/** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */
const emitDirectThreadEvents = (
...events: Array<Record<string, unknown>>
) => {
for (const event of events) {
directThreadEventSequence += 1;
directThreadPendingEventSeqs.set(event, directThreadEventSequence);
if (event.type === 'turn.started') directThreadTurnRunning = true;
if (event.type === 'turn.completed') directThreadTurnRunning = false;
if (event.type === 'turn.started' || event.type === 'turn.completed') {
// 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。
directThreadLifecycleAnchor = {
seq: directThreadEventSequence,
event,
};
}
const itemId = directThreadEventItemId(event);
if (itemId && event.type === 'item.started') {
directThreadActiveItemIds.add(itemId);
}
if (itemId && event.type === 'item.completed') {
directThreadActiveItemIds.delete(itemId);
}
}
pendingDirectThreadEvents.push(...events);
if (directThreadSubscriptionId) {
directThreadNotifyHandler?.({
payload: { subscriptionId: directThreadSubscriptionId },
});
}
};
return {
invoke,
listen,
projectPath,
sessionId,
runtimeState,
runtimeResult,
setConfirmRuntime(state: Record<string, unknown>) {
confirmRuntime = state;
},
setRejectRuntime(state: Record<string, unknown>) {
rejectRuntime = state;
},
setAnswerRuntime(state: Record<string, unknown>) {
answerRuntime = state;
},
failNextAnswers(count = 1) {
answerFailuresRemaining = count;
},
emitDesignAgentEvent(payload: Record<string, unknown>) {
designAgentUpdateHandler?.({ payload });
},
appendAgentMessage(message: Record<string, unknown>) {
currentAgentMessages.push(message);
},
setResponseStream(responseStream: Record<string, unknown> | null) {
currentResponseStream = responseStream;
},
setProjectRevision(revision: number) {
currentProjectRevision = revision;
},
setRuntimeReader(reader: (() => Promise<Record<string, unknown>>) | null) {
runtimeReader = reader;
},
emitRuntime(
state: Record<string, unknown>,
responseStream: Record<string, unknown> | null = currentResponseStream,
) {
currentRuntime = state;
currentResponseStream = responseStream;
runtimeUpdateHandler?.({
payload: {
projectPath,
agentId: 'planning-agent-v2',
runId: String(state.runId),
status: String(state.status),
phase: String(state.phase),
manifestInvalidated: true,
runtime: runtimeResult(currentRuntime, currentResponseStream),
},
});
},
emitAgentRuntime(state: Record<string, unknown>) {
runtimeUpdateHandler?.({
payload: {
projectPath,
agentId: String(state.agentId ?? ''),
runId: String(state.runId ?? ''),
status: String(state.status ?? ''),
phase: String(state.phase ?? ''),
manifestInvalidated: true,
runtime: runtimeResult(state, null),
},
});
},
emitManifestInvalidated(agentId: string, eventProjectPath = projectPath) {
for (const handler of [...manifestInvalidatedHandlers]) {
handler({
payload: {
projectPath: eventProjectPath,
agentId,
},
});
}
},
emitProgress(stage: string, message: string) {
progressHandler?.({
payload: {
projectPath,
stage,
message,
},
});
},
emitDirectThreadEvents,
/**
* 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。
*
* 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。
* 事件级 `at` 与条目 `item.at` 同源:原生在两个阶段事件上都给时间,计时只读事件级那一份。
*/
completeDirectThreadTurn({
turnId,
reply,
prompt = '',
at = 9_000,
status = 'completed',
}: {
turnId: string;
reply: string;
prompt?: string;
at?: number;
status?: string;
}) {
// 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。
const events: Array<Record<string, unknown>> = directThreadTurnRunning
? []
: [{ type: 'turn.started', at }];
if (prompt.trim()) {
events.push({
type: 'item.completed',
at,
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:user`,
role: 'user',
text: prompt,
at,
},
});
}
events.push({
type: 'item.completed',
at: at + 1,
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:assistant`,
role: 'assistant',
text: reply,
at: at + 1,
},
});
directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`;
// 终态时间就是调用方给的这一刻:整轮总耗时读它,不读正文条目的展示时间。
events.push({ type: 'turn.completed', status, at });
emitDirectThreadEvents(...events);
},
setDirectThreadHistory(items: Array<Record<string, unknown>>) {
directThreadHistoryItems = [...items];
// 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示
// 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。
const newest = directThreadHistoryItems.at(-1);
directThreadLastCompletedItemId =
typeof newest?.itemId === 'string' ? newest.itemId : null;
},
/** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */
setDirectThreadLastCompletedItemId(itemId: string | null) {
directThreadLastCompletedItemId = itemId;
},
};
}
async function openMainProject(projectPath: string) {
await submitChat(`/project ${projectPath}`);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(`已打开:${projectNameFromPath(projectPath)}`),
).not.toBeNull();
}
export function installResizeObserverStub() {
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,
});
return {
observerCount: () => observerCount,
observerDisconnected: () => observerDisconnected,
};
}
beforeEach(() => {
resetLlmModelCatalogCacheForTest();
vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({
defaultModelId: 'quality',
models: [
{ id: 'quality', displayName: '高质量' },
{ id: 'fast', displayName: '快速' },
],
revision: 1,
});
Object.defineProperty(window, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
});
Object.defineProperty(window, 'ClipboardEvent', {
configurable: true,
value: TestClipboardEvent,
});
Object.defineProperty(window, 'DragEvent', {
configurable: true,
value: TestDragEvent,
});
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: emptyRangeRect,
});
Object.defineProperties(URL, {
createObjectURL: {
configurable: true,
value: vi.fn(() => 'blob:mock-attachment-preview'),
},
revokeObjectURL: {
configurable: true,
value: vi.fn(),
},
});
});
afterEach(() => {
cleanup();
// 首页创作类型存在 module 级 zustand store 里,跨用例不会自动回到初始值。
// 建项按钮的文案依赖它(做方案 → 进入立项策划),漏一次就会让下一个用例
// 找不到「开启创作」。
useLauncherHomeDraftStore.getState().reset();
nativeClipboardMock.text = '';
window.history.pushState({}, '', '/');
window.localStorage.clear();
window.sessionStorage.clear();
delete window.__TAURI__;
vi.restoreAllMocks();
if (originalCreateObjectUrl) {
Object.defineProperty(URL, 'createObjectURL', originalCreateObjectUrl);
} else {
Reflect.deleteProperty(URL, 'createObjectURL');
}
if (originalRevokeObjectUrl) {
Object.defineProperty(URL, 'revokeObjectURL', originalRevokeObjectUrl);
} else {
Reflect.deleteProperty(URL, 'revokeObjectURL');
}
if (originalPointerEvent) {
Object.defineProperty(window, 'PointerEvent', originalPointerEvent);
} else {
Reflect.deleteProperty(window, 'PointerEvent');
}
if (originalClipboardEvent) {
Object.defineProperty(window, 'ClipboardEvent', originalClipboardEvent);
} else {
Reflect.deleteProperty(window, 'ClipboardEvent');
}
if (originalDragEvent) {
Object.defineProperty(window, 'DragEvent', originalDragEvent);
} else {
Reflect.deleteProperty(window, 'DragEvent');
}
if (originalRangeGetBoundingClientRect) {
Object.defineProperty(
Range.prototype,
'getBoundingClientRect',
originalRangeGetBoundingClientRect,
);
} else {
Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect');
}
if (originalIntersectionObserver) {
Object.defineProperty(
window,
'IntersectionObserver',
originalIntersectionObserver,
);
} else {
Reflect.deleteProperty(window, 'IntersectionObserver');
}
if (originalResizeObserver) {
Object.defineProperty(window, 'ResizeObserver', originalResizeObserver);
} else {
Reflect.deleteProperty(window, 'ResizeObserver');
}
});
export {
act,
agentRuntimeUserInputRequest,
App,
AuthenticatedClient,
cleanup,
composerDisabled,
composerText,
composerValue,
createGameCreationAppManifest,
createGameCreationAppSeedTasks,
createPlanGddStateView,
createProjectChatRuntimeHarness,
deriveAgentStatusCards,
describe,
emptyProjectPolicy,
expect,
findResourceSelectButton,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
getResourceSelectButton,
it,
mockRoleAgentReply,
nativeClipboardMock,
openMainProject,
openResourceFilterPanel,
pickProjectFromLauncher,
planningResponseStream,
ProjectDevelopmentView,
queryResourceSelectButton,
React,
readFileSync,
render,
renderAppAt,
renderLauncherAt,
renderLauncherProjectsAt,
resolve,
roleAgentMockReply,
screen,
setComposerText,
submitChat,
TEST_LOCAL_PROJECT_PATH,
testAuthUser,
vi,
waitFor,
within,
WorkspaceLauncher,
};
export type { GameCreationAgentRunTrace };