1164 lines
34 KiB
TypeScript
1164 lines
34 KiB
TypeScript
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 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;
|
|
}
|
|
}
|
|
|
|
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 submitChat(value: string) {
|
|
fireEvent.change(screen.getByLabelText('创作想法'), {
|
|
target: { value },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
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;
|
|
|
|
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: 'max',
|
|
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 === '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;
|
|
}
|
|
return () => {
|
|
if (runtimeUpdateHandler === handler) {
|
|
runtimeUpdateHandler = null;
|
|
}
|
|
if (manifestInvalidatedHandler === handler) {
|
|
manifestInvalidatedHandler = null;
|
|
}
|
|
if (progressHandler === handler) {
|
|
progressHandler = null;
|
|
}
|
|
};
|
|
},
|
|
);
|
|
|
|
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;
|
|
},
|
|
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) {
|
|
manifestInvalidatedHandler?.({
|
|
payload: {
|
|
projectPath,
|
|
agentId,
|
|
},
|
|
});
|
|
},
|
|
emitProgress(stage: string, message: string) {
|
|
progressHandler?.({
|
|
payload: {
|
|
projectPath,
|
|
stage,
|
|
message,
|
|
},
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function openMainProject(projectPath: string) {
|
|
submitChat(`/project ${projectPath}`);
|
|
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
|
expect(
|
|
await screen.findByText(`已打开:${projectNameFromPath(projectPath)}`),
|
|
).not.toBeNull();
|
|
}
|
|
|
|
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.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 (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,
|
|
createGameCreationAppManifest,
|
|
createGameCreationAppSeedTasks,
|
|
createPlanGddStateView,
|
|
createProjectSupervisorRuntimeHarness,
|
|
deriveAgentStatusCards,
|
|
describe,
|
|
emptyProjectPolicy,
|
|
expect,
|
|
fireEvent,
|
|
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
|
it,
|
|
mockRoleAgentReply,
|
|
nativeClipboardMock,
|
|
openMainProject,
|
|
pickProjectFromLauncher,
|
|
ProjectDevelopmentView,
|
|
projectSupervisorResponseStream,
|
|
React,
|
|
readFileSync,
|
|
render,
|
|
renderAppAt,
|
|
renderLauncherAgentChatAt,
|
|
renderLauncherAt,
|
|
renderLauncherProjectsAt,
|
|
resolve,
|
|
roleAgentMockReply,
|
|
screen,
|
|
selectDeveloperAgentChatMode,
|
|
submitChat,
|
|
TEST_LOCAL_PROJECT_PATH,
|
|
testAuthUser,
|
|
vi,
|
|
waitFor,
|
|
within,
|
|
WorkspaceLauncher,
|
|
};
|
|
export type { GameCreationAgentRunTrace };
|