Files
k88936 6106b4e67c 退役AGC项目对话斜杠命令与终端swarm chat入口:应用与Rust实现删除
- 删除应用侧 /history 精确匹配分支与 reloadHistory、chatPromptPolish 的 / 前缀绕过、chatCommandMetadata、memoryCommands、projectSummaryConstants 命令清单
- 删除只服务退役摘要面板的 project-summary/*Summaries.ts 与 agentTrace.ts 及对应测试
- 删除 /sync-canvas-project、/read、/trace 草稿回填死链(agentPresentation.ts 与 Rust suggested_canvas_tool_call)
- 删除无人调用的 Tauri 命令 get_game_creation_agent_capabilities 与 get_limited_local_commands
- 删除 --swarm-chat 入口、SwarmChat 变体、src/swarm_cli.rs 与整个 swarm_cli/ 目录
- 收敛 agent/interaction.rs 至自然语言 steer 决策路径,删除交互内核整层
- 删除 SWARM_TURN_*_ERROR、print_runtime_response_stream_status 及其专属测试
- 更新 ChatMarkdownMessage、chatPromptPolish、rememberCommand 与 appSurface 用例,移除斜杠命令断言
2026-09-23 11:12:39 +08:00

1564 lines
49 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 * 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 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();
}
// 同一张资源卡上的按钮变少了:卡片本体只有「选中资源」与媒体播放钮两个(原先右上角
// 那个 @ 引用圆钮已挪进选中工具条)。选中按钮的可访问名模板固定为
// `选中资源:<分类标签> <资源文件名>`;分类标签可能自带空格(例如 `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;
},
};
}
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,
openResourceFilterPanel,
pickProjectFromLauncher,
planningResponseStream,
ProjectDevelopmentView,
queryResourceSelectButton,
React,
readFileSync,
render,
renderAppAt,
renderLauncherAt,
renderLauncherProjectsAt,
resolve,
roleAgentMockReply,
screen,
setComposerText,
TEST_LOCAL_PROJECT_PATH,
testAuthUser,
vi,
waitFor,
within,
WorkspaceLauncher,
};
export type { GameCreationAgentRunTrace };