1e186369c9
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
822 lines
28 KiB
TypeScript
822 lines
28 KiB
TypeScript
import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences';
|
|
import * as platformSession from '../../src/services/platformSession';
|
|
import {
|
|
chatQueueFullNotice,
|
|
createQueuedChatTurn,
|
|
dequeueChatTurn,
|
|
enqueueChatTurn,
|
|
isChatTurnQueueFull,
|
|
queuedChatTurnLabel,
|
|
removeQueuedChatTurn,
|
|
} from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerQueue.ts';
|
|
import {
|
|
appendDictationText,
|
|
resolveSpeechRecognitionCtor,
|
|
speechRecognitionErrorMessage,
|
|
type SpeechRecognitionEventLike,
|
|
type SpeechRecognitionLike,
|
|
VOICE_INPUT_UNSUPPORTED_MESSAGE,
|
|
} from '../../src/view/project-development/chat/components/DirectProjectComposer/chatComposerVoice.ts';
|
|
import { ComposerVoiceButton } from '../../src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx';
|
|
import {
|
|
act,
|
|
createGameCreationAppManifest,
|
|
createProjectChatRuntimeHarness,
|
|
emptyProjectPolicy,
|
|
expect,
|
|
fireEvent,
|
|
it,
|
|
pickProjectFromLauncher,
|
|
React,
|
|
render,
|
|
renderLauncherProjectsAt,
|
|
screen,
|
|
setComposerText,
|
|
testAuthUser,
|
|
vi,
|
|
waitFor,
|
|
within,
|
|
} from './harness';
|
|
|
|
const DYNAMIC_GAME_PROJECT_PATH = '/tmp/chat-composer-controls-game';
|
|
|
|
type InvokeOverrides = Record<
|
|
string,
|
|
(args: Record<string, unknown> | undefined) => unknown
|
|
>;
|
|
|
|
function gameCreatorConfigView(reasoningEffort: string) {
|
|
return {
|
|
path: '/tmp/chat-composer-config.json',
|
|
config: {
|
|
schemaVersion: 'game-creator-config.v2',
|
|
agentMode: 'codex_app_server',
|
|
selectedModelId: 'quality',
|
|
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: '' },
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */
|
|
async function openDirectCodexSurface(
|
|
overrides: InvokeOverrides = {},
|
|
beforeOpen?: (
|
|
harness: ReturnType<typeof createProjectChatRuntimeHarness>,
|
|
) => void,
|
|
) {
|
|
const chatHarness = createProjectChatRuntimeHarness({
|
|
projectPath: DYNAMIC_GAME_PROJECT_PATH,
|
|
initialSessionExists: false,
|
|
});
|
|
beforeOpen?.(chatHarness);
|
|
const manifest = createGameCreationAppManifest(
|
|
'local-project-draft',
|
|
'输入盒控件项目',
|
|
);
|
|
const invoke = vi.fn(
|
|
async (command: string, args?: Record<string, unknown>) => {
|
|
const override = overrides[command];
|
|
if (override) {
|
|
return override(args);
|
|
}
|
|
if (command === 'get_design_agent_runtime_mode') return null;
|
|
if (command === 'inspect_local_project_directory') {
|
|
return {
|
|
projectPath: DYNAMIC_GAME_PROJECT_PATH,
|
|
exists: true,
|
|
isDirectory: true,
|
|
isGameCreatorProject: true,
|
|
projectName: 'chat-composer-controls',
|
|
recentRunStatus: null,
|
|
recentRunStopReason: null,
|
|
};
|
|
}
|
|
if (command === 'get_local_game_manifest') return manifest;
|
|
if (command === 'get_local_game_preview_status') {
|
|
return { status: 'stopped', url: null, port: null, root: null };
|
|
}
|
|
return chatHarness.invoke(command, args);
|
|
},
|
|
);
|
|
window.__TAURI__ = {
|
|
core: { invoke },
|
|
event: { listen: chatHarness.listen },
|
|
};
|
|
renderLauncherProjectsAt('/?launcher');
|
|
pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH);
|
|
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
|
return {
|
|
invoke,
|
|
path: DYNAMIC_GAME_PROJECT_PATH,
|
|
surface,
|
|
harness: chatHarness,
|
|
};
|
|
}
|
|
|
|
async function submitDirectTurn(
|
|
surface: HTMLElement,
|
|
composer: HTMLElement,
|
|
text: string,
|
|
) {
|
|
await setComposerText(composer, text);
|
|
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
|
}
|
|
|
|
/**
|
|
* 回合运行中发送钮位置是终止钮,Enter 仍然提交表单(`requestSubmit`),
|
|
* 因此"运行中再次发送"走的是表单提交而不是那颗按钮。
|
|
*/
|
|
function submitComposerForm(composer: HTMLElement) {
|
|
fireEvent.submit(composer.closest('form') as HTMLFormElement);
|
|
}
|
|
|
|
/**
|
|
* 一轮 Direct 回合发送的 canonical 文本:只从 `userItem.content` 读。
|
|
* 客户端投影文本(`prompt`)已从 IPC 契约删除,断言不得再依赖它。
|
|
*/
|
|
function directTurnInputText(args?: Record<string, unknown>) {
|
|
const content = (
|
|
args?.userItem as
|
|
| { content?: Array<{ type: string; text?: string }> }
|
|
| undefined
|
|
)?.content;
|
|
return (content ?? [])
|
|
.map((part) => (part.type === 'input_text' ? (part.text ?? '') : ''))
|
|
.join('');
|
|
}
|
|
|
|
/** 断言这一轮发出的 canonical 文本。 */
|
|
function expectDirectTurnWithText(text: string) {
|
|
return expect.objectContaining({
|
|
userItem: expect.objectContaining({
|
|
content: expect.arrayContaining([{ type: 'input_text', text }]),
|
|
}),
|
|
});
|
|
}
|
|
|
|
type FakeSpeechRecognition = SpeechRecognitionLike & {
|
|
emitTranscript: (transcript: string) => void;
|
|
};
|
|
|
|
function installFakeSpeechRecognition(): {
|
|
instances: FakeSpeechRecognition[];
|
|
restore: () => void;
|
|
} {
|
|
const instances: FakeSpeechRecognition[] = [];
|
|
function FakeSpeechRecognitionCtor(this: FakeSpeechRecognition) {
|
|
const instance = {
|
|
lang: '',
|
|
continuous: false,
|
|
interimResults: false,
|
|
maxAlternatives: 1,
|
|
onresult: null,
|
|
onerror: null,
|
|
onend: null,
|
|
start: vi.fn(),
|
|
stop: vi.fn(() => instance.onend?.()),
|
|
abort: vi.fn(),
|
|
emitTranscript: (transcript: string) => {
|
|
const event: SpeechRecognitionEventLike = {
|
|
resultIndex: 0,
|
|
results: {
|
|
length: 1,
|
|
0: { isFinal: true, length: 1, 0: { transcript } },
|
|
},
|
|
};
|
|
instance.onresult?.(event);
|
|
},
|
|
} as unknown as FakeSpeechRecognition;
|
|
instances.push(instance);
|
|
return instance;
|
|
}
|
|
const scope = window as unknown as Record<string, unknown>;
|
|
scope.webkitSpeechRecognition = FakeSpeechRecognitionCtor;
|
|
return {
|
|
instances,
|
|
restore: () => {
|
|
delete scope.webkitSpeechRecognition;
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 排队回合只持 canonical user item;展示文案由它派生。 */
|
|
function queuedTurn(
|
|
id: string,
|
|
clientTurnId: string,
|
|
text: string,
|
|
createdAt: number,
|
|
) {
|
|
return createQueuedChatTurn({
|
|
id,
|
|
clientTurnId,
|
|
userItem: directCodexUserItemFromContent(
|
|
[{ type: 'input_text', text }],
|
|
`${clientTurnId}:user`,
|
|
),
|
|
createdAt,
|
|
});
|
|
}
|
|
|
|
export function registerChatComposerControlTests() {
|
|
it('队列 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => {
|
|
const turn = createQueuedChatTurn({
|
|
id: 'turn-ref',
|
|
clientTurnId: 'client-ref',
|
|
userItem: directCodexUserItemFromContent(
|
|
[
|
|
{ type: 'input_text', text: '用这张图改一下' },
|
|
{ type: 'agc_resource_reference', resourceId: 'asset:hero' },
|
|
],
|
|
'client-ref:user',
|
|
),
|
|
createdAt: 1,
|
|
});
|
|
const manifestAssets = [
|
|
{
|
|
id: 'asset:hero',
|
|
kind: 'character',
|
|
mediaType: 'image/png',
|
|
localPath: 'assets/hero.png',
|
|
source: { kind: 'uploaded' as const },
|
|
},
|
|
];
|
|
|
|
// chip 文案与聊天输入区同口径:用户看到的是 `@显示名`,不是 `@内部 id`;
|
|
// 引用 token 前后各留一个空白(出站文本与反解析口径自洽)。
|
|
expect(queuedChatTurnLabel(turn, manifestAssets)).toBe(
|
|
'用这张图改一下 @hero',
|
|
);
|
|
});
|
|
|
|
it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => {
|
|
const first = queuedTurn('turn-1', 'client-1', '第一条', 1);
|
|
const second = queuedTurn('turn-2', 'client-2', '第二条', 2);
|
|
const third = queuedTurn('turn-3', 'client-3', '第三条', 3);
|
|
|
|
let queue = enqueueChatTurn([], first);
|
|
queue = enqueueChatTurn(queue, second);
|
|
queue = enqueueChatTurn(queue, third);
|
|
// 同一条消息重复入队不得变成两次发送。
|
|
expect(enqueueChatTurn(queue, third)).toHaveLength(3);
|
|
|
|
// FIFO:先入先出,不丢、不乱序。
|
|
const firstOut = dequeueChatTurn(queue);
|
|
expect(firstOut.next?.clientTurnId).toBe('client-1');
|
|
expect(firstOut.next && queuedChatTurnLabel(firstOut.next, [])).toBe(
|
|
'第一条',
|
|
);
|
|
expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn, []))).toEqual([
|
|
'第二条',
|
|
'第三条',
|
|
]);
|
|
|
|
// 单条取消只移除那一条,顺序不变。
|
|
expect(
|
|
removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) =>
|
|
queuedChatTurnLabel(turn, []),
|
|
),
|
|
).toEqual(['第三条']);
|
|
expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2);
|
|
|
|
// 空队列出队不报错、也不产生"幽灵消息"。
|
|
expect(dequeueChatTurn([]).next).toBeNull();
|
|
});
|
|
|
|
it('reports a readable reason instead of silently dropping a full queue', () => {
|
|
let queue: ReturnType<typeof createQueuedChatTurn>[] = [];
|
|
for (let index = 0; index < 5; index += 1) {
|
|
queue = enqueueChatTurn(
|
|
queue,
|
|
queuedTurn(`turn-${index}`, `client-${index}`, `第 ${index} 条`, index),
|
|
);
|
|
}
|
|
expect(isChatTurnQueueFull(queue)).toBe(true);
|
|
expect(chatQueueFullNotice()).toContain('队列已满');
|
|
expect(chatQueueFullNotice()).toContain('5');
|
|
expect(removeQueuedChatTurn(queue, 'turn-0')).toHaveLength(4);
|
|
});
|
|
|
|
it('degrades the voice input button with a readable hint when speech recognition is missing', async () => {
|
|
// jsdom 不提供 SpeechRecognition / webkitSpeechRecognition:必须禁用并说明原因。
|
|
expect(
|
|
resolveSpeechRecognitionCtor(window as unknown as object),
|
|
).toBeNull();
|
|
expect(
|
|
resolveSpeechRecognitionCtor({ SpeechRecognition: undefined }),
|
|
).toBeNull();
|
|
|
|
const onTranscript = vi.fn();
|
|
const onNotice = vi.fn();
|
|
render(
|
|
React.createElement(ComposerVoiceButton, {
|
|
disabled: false,
|
|
onTranscript,
|
|
onNotice,
|
|
}),
|
|
);
|
|
|
|
const button = screen.getByRole('button', {
|
|
name: VOICE_INPUT_UNSUPPORTED_MESSAGE,
|
|
});
|
|
expect(button).toHaveProperty('disabled', true);
|
|
expect(button.getAttribute('title')).toBe(VOICE_INPUT_UNSUPPORTED_MESSAGE);
|
|
fireEvent.click(button);
|
|
expect(onTranscript).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('marks the recording state and only appends dictated text', async () => {
|
|
const fake = installFakeSpeechRecognition();
|
|
try {
|
|
const onTranscript = vi.fn();
|
|
const onNotice = vi.fn();
|
|
render(
|
|
React.createElement(ComposerVoiceButton, {
|
|
disabled: false,
|
|
onTranscript,
|
|
onNotice,
|
|
}),
|
|
);
|
|
|
|
const button = screen.getByRole('button', { name: '语音输入' });
|
|
expect(button).toHaveProperty('disabled', false);
|
|
fireEvent.click(button);
|
|
|
|
// 录音态有明确视觉/可访问性反馈。
|
|
const recording = screen.getByRole('button', { name: '停止语音输入' });
|
|
expect(recording.getAttribute('aria-pressed')).toBe('true');
|
|
expect(recording.className).toContain('is-recording');
|
|
expect(fake.instances).toHaveLength(1);
|
|
|
|
act(() => {
|
|
fake.instances[0]?.emitTranscript('帮我做一个跳跃动作');
|
|
});
|
|
expect(onTranscript).toHaveBeenCalledWith('帮我做一个跳跃动作');
|
|
|
|
fireEvent.click(recording);
|
|
expect(
|
|
screen
|
|
.getByRole('button', { name: '语音输入' })
|
|
.getAttribute('aria-pressed'),
|
|
).toBe('false');
|
|
} finally {
|
|
fake.restore();
|
|
}
|
|
});
|
|
|
|
it('appends dictated text after the existing draft without overwriting it', () => {
|
|
expect(appendDictationText('', '帮我做一个跳跃动作')).toBe(
|
|
'帮我做一个跳跃动作',
|
|
);
|
|
// 中文直接拼接,用户已输入的内容原样保留在识别结果之前。
|
|
expect(appendDictationText('先做一个主菜单', '再补一个商店')).toBe(
|
|
'先做一个主菜单再补一个商店',
|
|
);
|
|
// 英文识别结果补一个空格,避免两个单词粘在一起。
|
|
expect(appendDictationText('add jump', 'dash')).toBe('add jump dash');
|
|
expect(appendDictationText('先做一个主菜单', ' ')).toBe('先做一个主菜单');
|
|
expect(speechRecognitionErrorMessage('not-allowed')).toContain(
|
|
'麦克风权限',
|
|
);
|
|
expect(speechRecognitionErrorMessage('network')).toContain('语音识别服务');
|
|
expect(speechRecognitionErrorMessage('no-speech')).toContain('重试');
|
|
});
|
|
|
|
it('settles only the final analytics attempt after a DirectProject authentication retry', async () => {
|
|
const { invoke, surface } = await openDirectCodexSurface({
|
|
chat_with_game_creator_direct_codex: (() => {
|
|
let attempts = 0;
|
|
return () => {
|
|
if (++attempts === 1) throw new Error('authentication-required');
|
|
return '完成';
|
|
};
|
|
})(),
|
|
});
|
|
const refresh = vi
|
|
.spyOn(platformSession, 'requestPlatformSessionRefresh')
|
|
.mockResolvedValue({
|
|
status: 'refreshed',
|
|
user: testAuthUser,
|
|
generation: platformSession.currentPlatformSessionGeneration(),
|
|
});
|
|
try {
|
|
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
|
await submitDirectTurn(surface, composer, '继续制作');
|
|
await waitFor(() => {
|
|
expect(
|
|
invoke.mock.calls.filter(
|
|
([command]) => command === 'settle_direct_run_analytics',
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
const attempts = invoke.mock.calls
|
|
.filter(
|
|
([command]) => command === 'chat_with_game_creator_direct_codex',
|
|
)
|
|
.map(([, args]) => args);
|
|
expect(attempts).toHaveLength(2);
|
|
expect(attempts[0]?.clientTurnId).toBe(attempts[1]?.clientTurnId);
|
|
expect(attempts[0]?.analyticsAttemptId).toEqual(expect.any(String));
|
|
expect(attempts[1]?.analyticsAttemptId).toEqual(expect.any(String));
|
|
expect(attempts[0]?.analyticsAttemptId).not.toBe(
|
|
attempts[1]?.analyticsAttemptId,
|
|
);
|
|
expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', {
|
|
attemptId: attempts[1]?.analyticsAttemptId,
|
|
discard: false,
|
|
});
|
|
expect(refresh).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
refresh.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => {
|
|
const pending: Array<{
|
|
resolve: (value: string) => void;
|
|
reject: (error: Error) => void;
|
|
}> = [];
|
|
const { invoke, surface } = await openDirectCodexSurface({
|
|
chat_with_game_creator_direct_codex: () =>
|
|
new Promise<string>((resolve, reject) => {
|
|
pending.push({ resolve, reject });
|
|
}),
|
|
});
|
|
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
|
await submitDirectTurn(surface, composer, '第一条消息');
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'chat_with_game_creator_direct_codex',
|
|
expectDirectTurnWithText('第一条消息'),
|
|
);
|
|
});
|
|
// 回合运行中:发送钮位置变成终止钮。
|
|
expect(
|
|
await within(surface).findByRole('button', { name: '终止' }),
|
|
).not.toBeNull();
|
|
|
|
await setComposerText(composer, '第二条消息');
|
|
submitComposerForm(composer);
|
|
await waitFor(() => {
|
|
expect(within(surface).getByText('第二条消息')).not.toBeNull();
|
|
});
|
|
await setComposerText(composer, '第三条消息');
|
|
submitComposerForm(composer);
|
|
|
|
const queue = await within(surface).findByLabelText('待发送消息队列');
|
|
await waitFor(() => {
|
|
expect(within(queue).getAllByRole('listitem')).toHaveLength(2);
|
|
});
|
|
expect(
|
|
within(queue)
|
|
.getAllByRole('listitem')
|
|
.map((item) => item.textContent),
|
|
).toEqual([
|
|
expect.stringContaining('第二条消息'),
|
|
expect.stringContaining('第三条消息'),
|
|
]);
|
|
|
|
// 单条取消:只移除第二条,第三条保留。
|
|
fireEvent.click(
|
|
within(queue).getByRole('button', { name: '取消排队消息 第二条消息' }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(within(queue).getAllByRole('listitem')).toHaveLength(1);
|
|
});
|
|
|
|
act(() => {
|
|
pending[0]?.resolve('第一条回复');
|
|
});
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'chat_with_game_creator_direct_codex',
|
|
expectDirectTurnWithText('第三条消息'),
|
|
);
|
|
});
|
|
// 发出去的就是「第一条 + 第三条」:被取消的第二条不在其中,队列顺序也不乱。
|
|
const sentTexts = invoke.mock.calls
|
|
.filter(([command]) => command === 'chat_with_game_creator_direct_codex')
|
|
.map(([, args]) => directTurnInputText(args));
|
|
expect(sentTexts).toEqual(['第一条消息', '第三条消息']);
|
|
|
|
act(() => {
|
|
pending[1]?.resolve('第三条回复');
|
|
});
|
|
await waitFor(() => {
|
|
expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('does not report a finished turn while the host has not acknowledged the send yet', async () => {
|
|
const pending: Array<{ resolve: (value: string) => void }> = [];
|
|
const { invoke, surface } = await openDirectCodexSurface({
|
|
chat_with_game_creator_direct_codex: () =>
|
|
new Promise<string>((resolve) => {
|
|
pending.push({ resolve });
|
|
}),
|
|
});
|
|
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
|
await submitDirectTurn(surface, composer, '窗口期的消息');
|
|
|
|
// 写权限门是异步的,先等命令真的发出去,否则下面的窗口期断言会在 invoke 还没发生时就
|
|
// 通过、收尾的 `pending[0]?.resolve` 也变成空操作,用例根本没盖住它要盖的窗口。
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'chat_with_game_creator_direct_codex',
|
|
expectDirectTurnWithText('窗口期的消息'),
|
|
);
|
|
});
|
|
|
|
// 本地乐观气泡立刻可见;此刻原生既没回 turn.started,也没回显用户条目,
|
|
// 这一轮属于「本地已发出、宿主未确认」,不得渲染成已结束。
|
|
await waitFor(() => {
|
|
expect(within(surface).getByText('窗口期的消息')).not.toBeNull();
|
|
});
|
|
expect(within(surface).queryByText(/本轮结束于/)).toBeNull();
|
|
expect(within(surface).queryByTestId('turn-usage')).toBeNull();
|
|
|
|
await act(async () => {
|
|
pending[0]?.resolve('回复');
|
|
});
|
|
});
|
|
|
|
it('stops claiming the turn is running when a failed send left turn.started open', async () => {
|
|
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
|
|
null;
|
|
const { surface } = await openDirectCodexSurface(
|
|
{
|
|
chat_with_game_creator_direct_codex: (
|
|
args: Record<string, unknown> | undefined,
|
|
) => {
|
|
// 宿主先认领了这一轮(turn.started),随后崩掉:没有终态事件,命令以失败返回。
|
|
harness?.emitDirectThreadEvents({
|
|
type: 'turn.started',
|
|
at: 5_000,
|
|
userItemId: `direct-codex:${String(args?.clientTurnId ?? '')}:user`,
|
|
});
|
|
throw new Error('模拟宿主崩溃:turn.started 之后没有终态事件');
|
|
},
|
|
},
|
|
(directHarness) => {
|
|
harness = directHarness;
|
|
},
|
|
);
|
|
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
|
await submitDirectTurn(surface, composer, '崩掉的那条');
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
within(surface).getAllByText('陶泥儿智能创作 执行失败,请稍后重试')
|
|
.length,
|
|
).toBeGreaterThan(0);
|
|
});
|
|
// 命令已经收场:卡片和输入区都不能再声称"还在处理"。
|
|
expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0);
|
|
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
|
|
expect(
|
|
within(surface).getByRole('button', { name: '发送' }),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it('keeps the next queued turn busy when the write gate refuses the running one', async () => {
|
|
const pending: Array<{ resolve: (value: string) => void }> = [];
|
|
const deferredPolicies: Array<(value: unknown) => void> = [];
|
|
let policyAllowsWrite = false;
|
|
const { invoke, surface } = await openDirectCodexSurface({
|
|
read_project_permission_policy: () => {
|
|
if (policyAllowsWrite) return Promise.resolve(emptyProjectPolicy());
|
|
return new Promise((resolve) => {
|
|
deferredPolicies.push(resolve);
|
|
});
|
|
},
|
|
chat_with_game_creator_direct_codex: () =>
|
|
new Promise<string>((resolve) => {
|
|
pending.push({ resolve });
|
|
}),
|
|
});
|
|
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
|
await submitDirectTurn(surface, composer, '被拒的那条');
|
|
|
|
// 权限门还没回,先把第二条排进队列:后面那条要等被拒的这一轮出队才会发出去。
|
|
await setComposerText(composer, '后面那条');
|
|
submitComposerForm(composer);
|
|
const queue = await within(surface).findByLabelText('待发送消息队列');
|
|
expect(within(queue).getByText('后面那条')).not.toBeNull();
|
|
|
|
// 写权限门拒绝这一轮(策略要求确认,且此刻没有 onConfirmed):这一轮不会重跑,
|
|
// 队列必须继续走,而它出队后那一轮仍要算「命令在飞」。
|
|
policyAllowsWrite = true;
|
|
await act(async () => {
|
|
for (const resolve of deferredPolicies.splice(0)) {
|
|
resolve({
|
|
path: '.agent/policy.json',
|
|
policy: {
|
|
deniedCommands: [],
|
|
confirmCommands: ['conversation.write'],
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'chat_with_game_creator_direct_codex',
|
|
expectDirectTurnWithText('后面那条'),
|
|
);
|
|
});
|
|
// 被出队的那一轮还在飞:上一轮的收尾不得把它刚设上的忙态清掉。
|
|
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
|
|
expect(
|
|
within(surface).getByRole('button', { name: '终止' }),
|
|
).not.toBeNull();
|
|
|
|
await act(async () => {
|
|
pending[0]?.resolve('回复');
|
|
});
|
|
});
|
|
|
|
it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => {
|
|
const pending: Array<{ resolve: (value: string) => void }> = [];
|
|
const { invoke, surface, harness } = await openDirectCodexSurface(
|
|
{
|
|
chat_with_game_creator_direct_codex: () =>
|
|
new Promise<string>((resolve) => {
|
|
pending.push({ resolve });
|
|
}),
|
|
},
|
|
(directHarness) => {
|
|
directHarness.emitDirectThreadEvents({ type: 'turn.started' });
|
|
},
|
|
);
|
|
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
|
|
|
expect(
|
|
await within(surface).findByRole('button', { name: '终止' }),
|
|
).not.toBeNull();
|
|
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
|
|
|
|
await setComposerText(composer, '恢复后排队的消息');
|
|
submitComposerForm(composer);
|
|
const queue = await within(surface).findByLabelText('待发送消息队列');
|
|
expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull();
|
|
expect(
|
|
invoke.mock.calls.filter(
|
|
([command]) => command === 'chat_with_game_creator_direct_codex',
|
|
),
|
|
).toHaveLength(0);
|
|
|
|
act(() => {
|
|
harness.emitDirectThreadEvents({
|
|
type: 'turn.completed',
|
|
status: 'completed',
|
|
});
|
|
});
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'chat_with_game_creator_direct_codex',
|
|
expectDirectTurnWithText('恢复后排队的消息'),
|
|
);
|
|
});
|
|
expect(
|
|
invoke.mock.calls.filter(
|
|
([command]) => command === 'chat_with_game_creator_direct_codex',
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
await act(async () => {
|
|
pending[0]?.resolve('排队回合回复');
|
|
});
|
|
});
|
|
|
|
it('terminates the running turn and returns the composer to the idle state', async () => {
|
|
const pending: Array<{
|
|
resolve: (value: string) => void;
|
|
reject: (error: Error) => void;
|
|
}> = [];
|
|
const { invoke, path, surface, harness } = await openDirectCodexSurface({
|
|
chat_with_game_creator_direct_codex: () =>
|
|
new Promise<string>((resolve, reject) => {
|
|
// 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。
|
|
harness.emitDirectThreadEvents({ type: 'turn.started' });
|
|
pending.push({ resolve, reject });
|
|
}),
|
|
cancel_direct_codex_turn: async () => undefined,
|
|
});
|
|
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
|
await submitDirectTurn(surface, composer, '做一个小游戏');
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'chat_with_game_creator_direct_codex',
|
|
expectDirectTurnWithText('做一个小游戏'),
|
|
);
|
|
});
|
|
|
|
const stopButton = await within(surface).findByRole('button', {
|
|
name: '终止',
|
|
});
|
|
fireEvent.click(stopButton);
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', {
|
|
projectPath: path,
|
|
});
|
|
});
|
|
|
|
// app-server 的中断原因回到前端:不是失败,UI 必须回到可用态。
|
|
act(() => {
|
|
pending[0]?.reject(new Error('Codex app-server turn 已中断'));
|
|
harness.emitDirectThreadEvents({
|
|
type: 'turn.completed',
|
|
status: 'interrupted',
|
|
});
|
|
});
|
|
await waitFor(() => {
|
|
const send = within(surface).getByRole('button', { name: '发送' });
|
|
expect(send).toHaveProperty('disabled', false);
|
|
});
|
|
expect(within(surface).getByText('已终止本次回合。')).not.toBeNull();
|
|
});
|
|
|
|
it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => {
|
|
let stored = 'high';
|
|
const { invoke, surface } = await openDirectCodexSurface({
|
|
select_game_creator_reasoning_effort: (args) => {
|
|
stored = String(args?.effort ?? '');
|
|
return gameCreatorConfigView(stored);
|
|
},
|
|
});
|
|
|
|
const select = within(surface).getByRole('button', { name: '推理档' });
|
|
await waitFor(() => {
|
|
expect(select.textContent).toContain('高');
|
|
});
|
|
// 档位就在模型选择器这一排(同一控制排容器里)。
|
|
expect(
|
|
within(surface).getByRole('button', { name: '对话模型' }),
|
|
).not.toBeNull();
|
|
expect(select.closest('.project-chat-composer-controls')).not.toBeNull();
|
|
|
|
fireEvent.click(select);
|
|
fireEvent.click(within(surface).getByRole('option', { name: '低' }));
|
|
await waitFor(() => {
|
|
expect(invoke).toHaveBeenCalledWith(
|
|
'select_game_creator_reasoning_effort',
|
|
{ effort: 'low' },
|
|
);
|
|
});
|
|
// 以落盘后的回读值为准。
|
|
await waitFor(() => {
|
|
expect(
|
|
within(surface).getByRole('button', { name: '推理档' }).textContent,
|
|
).toContain('低');
|
|
});
|
|
expect(stored).toBe('low');
|
|
// 只影响后续回合:当前没有发出任何新一轮直接对话。
|
|
expect(
|
|
invoke.mock.calls.some(
|
|
([command]) => command === 'chat_with_game_creator_direct_codex',
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('appends the dictated transcript after the draft typed into the composer', async () => {
|
|
const fake = installFakeSpeechRecognition();
|
|
try {
|
|
const { surface } = await openDirectCodexSurface();
|
|
const composer = within(surface).getByLabelText('陶泥儿对话内容');
|
|
await setComposerText(composer, '已经写好的需求');
|
|
|
|
fireEvent.click(
|
|
within(surface).getByRole('button', { name: '语音输入' }),
|
|
);
|
|
await waitFor(() => {
|
|
expect(fake.instances).toHaveLength(1);
|
|
});
|
|
act(() => {
|
|
fake.instances[0]?.emitTranscript('再补一个跳跃动作');
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(composer.textContent).toContain('已经写好的需求');
|
|
expect(composer.textContent).toContain('再补一个跳跃动作');
|
|
});
|
|
} finally {
|
|
fake.restore();
|
|
}
|
|
});
|
|
}
|