Files
lhk229 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
客户端埋点设置 (#446)
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>
2026-09-23 00:09:12 +08:00

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();
}
});
}