Files
k88936 30c377d0f0
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m21s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m51s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 5m54s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m28s
Project CI / AI game creator shell Rust crates (push) Successful in 1m53s
Project CI / Repository checks (push) Successful in 5m33s
Project CI / Frontend tests (push) Successful in 7m20s
Project CI / Native shell tests (push) Successful in 8m29s
Project CI / Backend tests (push) Successful in 8m57s
Project CI / AI game creator shell web tests (push) Successful in 3m16s
保留唯一的thread manager作为direct project的状态来源 (#384)
说明: 在把工作交给段哥前还没有实现direct project聊天页面的迁移, 导致在 #375 里用很复杂的实现又做了一套事件流, 实测还有会话丢失的bug, 我在这里把数据获取的部分迁移到 #367 上

---------

Co-authored-by: 孔令弘 <ink29535@proton.me>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/384
Reviewed-by: 孔令弘 <ink29535@proton.me>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-09-18 02:13:50 +08:00

1649 lines
52 KiB
TypeScript
Raw Permalink 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 type { PlanGddStateViewV1 } from '../../src/app/types';
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 也没有 getBoundingClientRectLexical 计算选区矩形时会抛 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' | 'agent-chat' = '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 renderLauncherAgentChatAt(path: string) {
renderLauncherAt(path, 'agent-chat');
}
function selectDeveloperAgentChatMode(mode: 'run' | 'chat' | 'goal') {
fireEvent.click(
screen.getByRole('button', {
name: mode === 'run' ? '执行' : mode === 'chat' ? '聊天' : '目标',
}),
);
}
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 读 valueLexical 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 projectSupervisorResponseStream({
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: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'supervisor-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: 'project-supervisor',
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 createProjectSupervisorRuntimeHarness({
projectPath = '/tmp/authorized-game',
sessionId = 'supervisor-session-active',
initialSessionExists = true,
projectMessages = [],
supervisorMessages = [],
initialRuntime,
initialResponseStream = null,
initialProjectRevision = 0,
runtimeMapLoader,
expectedRunProfile = 'autonomous-game-build',
planningV2Result = null,
planningV2StartResult = null,
designAgentView = null,
designAgentContinueView = null,
designWorkspaceFiles = [],
}: {
projectPath?: string;
sessionId?: string;
initialSessionExists?: boolean;
projectMessages?: Array<Record<string, unknown>>;
supervisorMessages?: Array<Record<string, unknown>>;
initialRuntime?: Record<string, unknown>;
initialResponseStream?: Record<string, unknown> | null;
initialProjectRevision?: number;
runtimeMapLoader?: () => Promise<Array<Record<string, unknown>>>;
expectedRunProfile?: 'standard' | 'autonomous-game-build';
planningV2Result?: Record<string, unknown> | null;
planningV2StartResult?: Record<string, unknown> | null;
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 steerSequence = 0;
let sessionExists = initialSessionExists;
let currentProjectRevision = initialProjectRevision;
const currentProjectMessages = [...projectMessages];
const currentSupervisorMessages = [...supervisorMessages];
const runtimeState = (
overrides: Record<string, unknown> = {},
): Record<string, unknown> => ({
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId,
runId: 'supervisor-idle',
source: 'project-supervisor',
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;
// 默认不配置策划状态:hydrate 与接入本 harness 之前一样抛出,既有用例行为不变。
let currentPlanningV2Result = planningV2Result;
let currentPlanningV2StartResult = planningV2StartResult;
let currentDesignAgentView = designAgentView;
const currentDesignContinueView = designAgentContinueView;
const currentDesignWorkspaceFiles = [...designWorkspaceFiles];
let planningV2DecisionError: string | null = null;
const planningV2DecisionCalls: Array<Record<string, unknown>> = [];
let runtimeUpdateHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
runId: string;
status: string;
phase: string;
manifestInvalidated: boolean;
runtime: Record<string, unknown>;
};
}) => void)
| null = null;
let manifestInvalidatedHandler:
| ((event: {
payload: {
projectPath: string;
agentId: string;
};
}) => void)
| null = null;
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: 'project-supervisor',
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 === 'read_game_creator_app_config' ||
command === 'select_game_creator_model'
) {
if (command === 'select_game_creator_model')
selectedModelId = String(args?.modelId);
return {
path: '/tmp/test-game-creator-config.json',
config: {
schemaVersion: 'game-creator-config.v2',
agentMode: 'codex_app_server',
selectedModelId,
llm: {
apiKey: '',
baseUrl: '',
model: 'quality',
apiKind: 'openai_responses',
reasoningEffort: 'high',
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/project-supervisor/sessions.json`,
agentId: 'project-supervisor',
activeSessionId: sessionExists ? sessionId : null,
sessions: sessionExists
? [
{
sessionId,
title: '项目总控',
createdAt: 1000,
updatedAt: 2000,
archivedAt: null,
messageCount: currentSupervisorMessages.length,
legacy: false,
},
]
: [],
};
}
if (command === 'create_game_creator_agent_session') {
sessionExists = true;
return {
path: `${projectPath}/.agent/conversations/agents/project-supervisor/sessions.json`,
agentId: 'project-supervisor',
activeSessionId: sessionId,
sessions: [
{
sessionId,
title: String(args?.title ?? ''),
createdAt: 1000,
updatedAt: 2000,
archivedAt: null,
messageCount: currentSupervisorMessages.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 === 'project-supervisor') {
if (args?.sessionId !== sessionId) {
throw new Error(`unexpected supervisor session ${args?.sessionId}`);
}
return {
path: `${projectPath}/.agent/conversations/agents/project-supervisor/sessions/${sessionId}.jsonl`,
agentId: 'project-supervisor',
sessionId,
messages: [...currentSupervisorMessages],
};
}
}
if (command === 'append_local_conversation_message') {
if (args?.agentId !== null) {
throw new Error('React must not append Supervisor 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 !== 'project-supervisor' ||
args?.sessionId !== sessionId
) {
throw new Error('unexpected Project Supervisor 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 === 'start_game_creator_supervisor_runtime_task') {
if (args?.runProfile !== expectedRunProfile) {
throw new Error('unexpected Project Supervisor run profile');
}
const runId = String(args?.runId ?? '');
currentSupervisorMessages.push(
conversationRecord(
'user',
String(args?.task ?? ''),
`start-user-${runId}`,
),
);
currentRuntime = runtimeState({
runId,
runProfile: args?.runProfile,
source: args?.source ?? 'project-supervisor',
status: 'running',
phase: 'planning',
currentTask: String(args?.task ?? ''),
currentGoal: String(args?.task ?? ''),
updatedAt: 3000,
});
currentResponseStream = null;
return runtimeResult();
}
if (command === 'steer_game_creator_agent_runtime_task') {
steerSequence += 1;
currentResponseStream = null;
currentSupervisorMessages.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>;
currentSupervisorMessages.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 '';
}
if (command === 'hydrate_planning_session_v2') {
return currentPlanningV2Result;
}
if (
command === 'start_planning_session_v2' ||
command === 'continue_planning_session_v2'
) {
const nextResult =
command === 'start_planning_session_v2'
? (currentPlanningV2StartResult ?? currentPlanningV2Result)
: currentPlanningV2Result;
if (!nextResult) {
throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED');
}
currentPlanningV2Result = nextResult;
return nextResult;
}
if (command === 'decide_planning_artifact_v2') {
planningV2DecisionCalls.push({ ...(args ?? {}) });
if (planningV2DecisionError) {
const failure = planningV2DecisionError;
planningV2DecisionError = null;
throw new Error(failure);
}
if (!currentPlanningV2Result) {
throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED');
}
return currentPlanningV2Result;
}
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') {
manifestInvalidatedHandler =
handler as unknown as typeof 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;
}
if (manifestInvalidatedHandler === handler) {
manifestInvalidatedHandler = null;
}
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;
},
setPlanningV2Result(state: Record<string, unknown> | null) {
currentPlanningV2Result = state;
},
emitDesignAgentEvent(payload: Record<string, unknown>) {
designAgentUpdateHandler?.({ payload });
},
setPlanningV2StartResult(state: Record<string, unknown> | null) {
currentPlanningV2StartResult = state;
},
failNextPlanningV2Decision(message: string) {
planningV2DecisionError = message;
},
planningV2DecisionCalls,
appendSupervisorMessage(message: Record<string, unknown>) {
currentSupervisorMessages.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: 'project-supervisor',
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) {
manifestInvalidatedHandler?.({
payload: {
projectPath: eventProjectPath,
agentId,
},
});
},
emitProgress(stage: string, message: string) {
progressHandler?.({
payload: {
projectPath,
stage,
message,
},
});
},
emitDirectThreadEvents,
/**
* 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。
*
* 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。
*/
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' }];
if (prompt.trim()) {
events.push({
type: 'item.completed',
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:user`,
role: 'user',
text: prompt,
at,
},
});
}
events.push({
type: 'item.completed',
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 });
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,
createProjectSupervisorRuntimeHarness,
deriveAgentStatusCards,
describe,
emptyProjectPolicy,
expect,
findResourceSelectButton,
fireEvent,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
getResourceSelectButton,
it,
mockRoleAgentReply,
nativeClipboardMock,
openMainProject,
openResourceFilterPanel,
pickProjectFromLauncher,
ProjectDevelopmentView,
projectSupervisorResponseStream,
queryResourceSelectButton,
React,
readFileSync,
render,
renderAppAt,
renderLauncherAgentChatAt,
renderLauncherAt,
renderLauncherProjectsAt,
resolve,
roleAgentMockReply,
screen,
selectDeveloperAgentChatMode,
setComposerText,
submitChat,
TEST_LOCAL_PROJECT_PATH,
testAuthUser,
vi,
waitFor,
within,
WorkspaceLauncher,
};
export type { GameCreationAgentRunTrace };