Files
Genarrative/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts
T
k88936 0270cd601e DirectProject 埋点成绩改由宿主结算,删掉候选/settle 两阶段与 analyticsAttemptId
- analytics/run.rs:删 Request::DirectCandidate / Request::Settle 与 settle();direct_finished 改收放行代次,终态再读一次 identity_generation,不同则整条不记,相同则直接落 Request::Terminal
- analytics/store.rs:删 pending_runs / pending_run_bytes 与两条候选分支(连带 16 条上限),run_request 不再需要字节预算参数
- analytics/gui.rs + main.rs:删 settle_direct_run_analytics 命令与注册
- 放行侧:kick_queue_dispatch 在读 claim 之后读一次平台会话代次并随整轮传下去;user_input 命令不再收 analyticsAttemptId,队列条目不再持有埋点句柄
- 渲染层:删 beginDirectRunAnalytics、句柄表与结算 effect、settle 调用与命令参数;删除 tests/directRunAnalytics.test.ts,appSurface 两处断言同步
- 判据口径随放行搬家:从“发送时与终态同代”改成“放行时与终态同代”,入队后放行前的账号切换不再丢弃成绩
- 验证:cargo test -- analytics::(45 passed,含新增的直写与代际变化两条)、cargo test -- agent::(949 passed)、analytics_real_file_write 用例、npx vitest run tests/appSurface.test.ts(194 passed)、相关 chat/direct 单测 139 passed、typecheck 通过
2026-09-30 16:25:05 +08:00

1103 lines
42 KiB
TypeScript
Raw 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 { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences';
import * as platformSession from '../../src/services/platformSession';
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 { pendingTurnChipLabel } from '../../src/view/project-development/chat/components/DirectProjectComposer/pendingTurnChipLabel.ts';
import {
type DirectPendingTurn,
enqueuePendingTurn,
removePendingTurn,
} from '../../src/view/project-development/chat/conversation/directPendingTurns.ts';
import {
act,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
expect,
fireEvent,
it,
pickProjectFromLauncher,
React,
render,
renderLauncherProjectsAt,
screen,
setComposerText,
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,chip 文案由它派生。
*
* 身份换算与生产同源(`direct-codex:{clientTurnId}:{role}`),不另造一套。
*/
function pendingTurn(
clientTurnId: string,
text: string,
at = 1,
): DirectPendingTurn {
return {
clientTurnId,
userItem: directCodexUserItemFromContent(
[{ type: 'input_text', text }],
`direct-codex:${clientTurnId}:user`,
),
at,
};
}
/**
* 宿主的队列动作在测试里的替身:入队、取消、回合收尾后放行队首。
*
* 队列归宿主之后界面看到的每一步都来自事件——入队发 `queue.enqueued`,线程空闲时在同一次认领里
* 追加 `queue.removed{dispatched}` 与 `turn.started`,取消发 `queue.removed{cancelled}`。
* 这里只做 Thread Manager 会做的那几件事,顺序就是入队顺序(认领永远认队首)。
*/
function hostQueue(
harness: ReturnType<typeof createProjectChatRuntimeHarness>,
/** 打开面板时线程上是否已经有一条没收口的回合(恢复出来的那一轮)。 */
{ running: alreadyRunning = false }: { running?: boolean } = {},
) {
const queued: Array<{ clientTurnId: string; userItem: unknown }> = [];
let running = alreadyRunning;
/** 正在跑那一轮的回合身份:与宿主一致,`alreadyDispatched` 只认它(不是"随便哪一轮在跑")。 */
let dispatchedClientTurnId: string | null = null;
const dispatchHeadIfIdle = () => {
if (running) return;
const head = queued.shift();
if (!head) return;
running = true;
dispatchedClientTurnId = head.clientTurnId;
harness.emitDirectThreadEvents(
{
type: 'queue.removed',
clientTurnId: head.clientTurnId,
reason: 'dispatched',
at: 2,
},
{
type: 'turn.started',
at: 2,
userItemId: `direct-codex:${head.clientTurnId}:user`,
},
);
};
return {
/** 入队命令的宿主实现:入队 → 线程空闲就顺手放行。 */
enqueue(args?: Record<string, unknown>) {
const clientTurnId = String(args?.clientTurnId ?? '');
queued.push({ clientTurnId, userItem: args?.userItem });
harness.emitDirectThreadEvents({
type: 'queue.enqueued',
clientTurnId,
userItem: args?.userItem,
at: 1,
});
dispatchHeadIfIdle();
return null;
},
/** 取消命令的宿主实现:真的在队里才移除,并广播原因。 */
cancel(args?: Record<string, unknown>) {
const clientTurnId = String(args?.clientTurnId ?? '');
const index = queued.findIndex(
(item) => item.clientTurnId === clientTurnId,
);
if (index < 0) {
return dispatchedClientTurnId === clientTurnId
? 'alreadyDispatched'
: 'notFound';
}
queued.splice(index, 1);
harness.emitDirectThreadEvents({
type: 'queue.removed',
clientTurnId,
reason: 'cancelled',
at: 3,
});
return 'removed';
},
/** 回合收尾:宿主在占用释放那一刻踢一脚,队首接上。 */
complete(status = 'completed') {
running = false;
dispatchedClientTurnId = null;
harness.emitDirectThreadEvents({ type: 'turn.completed', status, at: 4 });
dispatchHeadIfIdle();
},
};
}
export function registerChatComposerControlTests() {
it('待发消息 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => {
const turn: DirectPendingTurn = {
clientTurnId: 'client-ref',
userItem: directCodexUserItemFromContent(
[
{ type: 'input_text', text: '用这张图改一下' },
{ type: 'agc_resource_reference', resourceId: 'asset:hero' },
],
'direct-codex:client-ref:user',
),
at: 1,
};
const manifestAssets = [
{
id: 'asset:hero',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/hero.png',
source: { kind: 'uploaded' as const },
},
];
// chip 文案与聊天输入区同口径:用户看到的是 `@显示名`,不是 `@内部 id`;
// 引用 token 前后各留一个空白(出站文本与反解析口径自洽)。
expect(pendingTurnChipLabel(turn, manifestAssets)).toBe(
'用这张图改一下 @hero',
);
// 空内容也给一行可显示的字,不让 chip 变成一条空白带。
expect(pendingTurnChipLabel(pendingTurn('client-empty', ''), [])).toBe(
'未命名消息',
);
});
it('folds the pending queue from events: FIFO append, identity removal, idempotent enqueue', () => {
let pending: readonly DirectPendingTurn[] = [];
pending = enqueuePendingTurn(pending, pendingTurn('client-1', '第一条'));
pending = enqueuePendingTurn(pending, pendingTurn('client-2', '第二条'));
pending = enqueuePendingTurn(pending, pendingTurn('client-3', '第三条'));
// 顺序就是事件顺序(宿主认领队首,所以界面不许自己排)。
expect(pending.map((turn) => pendingTurnChipLabel(turn, []))).toEqual([
'第一条',
'第二条',
'第三条',
]);
// 同一条消息重复入队不产生第二个 chip:宿主的入队是幂等的,投影这一层也按身份折回原状态
// (引用不变,调用方能直接跳过重渲染)。
expect(enqueuePendingTurn(pending, pendingTurn('client-3', '第三条'))).toBe(
pending,
);
// 取消与放行共用同一条移除:按身份删,其余顺序不变。
expect(
removePendingTurn(pending, 'client-2').map((turn) =>
pendingTurnChipLabel(turn, []),
),
).toEqual(['第一条', '第三条']);
// 身份不在队里时原样返回(放行事件与 `turn.started` 同批到达、或事件被重放)。
expect(removePendingTurn(pending, 'client-missing')).toBe(pending);
});
it('keeps the draft and explains why when the host rejects the enqueue as full', async () => {
// 队列上限只在宿主一处(`MAX_PENDING_DIRECT_TURNS`):界面不再自己数一份、不再自己造一句
// 提示,只把 typed 入队失败显示出来——**草稿留着**,用户等前面几条发完就能直接再按发送。
const { invoke, surface } = await openDirectCodexSurface({
enqueue_direct_codex_turn: () => {
throw {
error: { type: 'queueFull', limit: 5 },
message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送',
};
},
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '第六条消息');
const conversation = await within(surface).findByLabelText('陶泥儿消息');
await waitFor(() => {
expect(
within(conversation).getByText(
'待发消息已达上限(最多 5 条),请等前面几条发完再发送',
),
).not.toBeNull();
});
// 草稿没丢:这条消息还在输入盒里,用户改一改或等一会儿就能重发。
expect(composer.textContent ?? '').toContain('第六条消息');
expect(
within(surface).getByRole('button', { name: '发送' }),
).not.toBeNull();
// 入队没成立就是没成立:只发了一次命令,也不会有回合事件来结算埋点。
expect(
invoke.mock.calls.filter(
([command]) => command === 'enqueue_direct_codex_turn',
),
).toHaveLength(1);
});
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('does not re-run the whole DirectProject turn when authentication fails', async () => {
// 登录态失效不再"刷新 + 重跑整轮"(重跑会重复落盘同一条用户消息):它按普通回合结果呈现,
// 整轮只 invoke 一次。
const { invoke, surface } = await openDirectCodexSurface({
enqueue_direct_codex_turn: () => {
throw new Error('authentication-required');
},
});
// 桩掉实现:真回归(回合期间误调保活刷新)时以 mock 结果干净失败,不在测试里发起真实刷新。
const refresh = vi
.spyOn(platformSession, 'requestPlatformSessionRefresh')
.mockResolvedValue({ status: 'stale' });
try {
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '继续制作');
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'enqueue_direct_codex_turn',
),
).toHaveLength(1);
});
const attempts = invoke.mock.calls.filter(
([command]) => command === 'enqueue_direct_codex_turn',
);
expect(attempts).toHaveLength(1);
expect(refresh).not.toHaveBeenCalled();
// 认不出的入队失败 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。
await waitFor(() => {
expect(
within(surface).getByText('陶泥儿智能创作 执行失败,请稍后重试'),
).not.toBeNull();
});
} finally {
refresh.mockRestore();
}
});
it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => {
// 宿主 / 环境事实的入队失败没有放行、也就不产生 `turn.completed`:这一轮在聊天区里根本不存在
// (本地不再造用户气泡),说明必须由命令边界补一条;上报与横幅照旧保留。
const { surface } = await openDirectCodexSurface({
enqueue_direct_codex_turn: () => {
throw {
error: {
type: 'environmentNotReady',
detail: 'Codex app-server 启动失败:找不到可执行文件',
},
message:
'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断',
};
},
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '起不来也要说一声');
const conversation = await within(surface).findByLabelText('陶泥儿消息');
await waitFor(() => {
expect(
within(conversation).getByText(
'陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断',
),
).not.toBeNull();
});
// 机器字段(`direct-codex-failure:v2` / `stage=` / `code=`)只留在宿主侧。
expect(conversation.textContent ?? '').not.toContain(
'direct-codex-failure',
);
expect(conversation.textContent ?? '').not.toContain('stage=');
// 入队失败没有成立:忙碌态要放掉,用户能直接重发。
await waitFor(() => {
expect(
within(surface).getByRole('button', { name: '发送' }),
).not.toBeNull();
});
});
it('enqueues messages sent while a turn runs, cancels one chip, and never sends the same message twice', async () => {
let host: ReturnType<typeof hostQueue> | null = null;
const { invoke, surface, harness } = await openDirectCodexSurface({
// 宿主替身:入队就发 `queue.enqueued`,线程空闲时同一次认领里放行队首。
enqueue_direct_codex_turn: (args) => host?.enqueue(args),
remove_direct_project_pending_turn: (args) => host?.cancel(args),
});
host = hostQueue(harness);
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '第一条消息');
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'enqueue_direct_codex_turn',
expectDirectTurnWithText('第一条消息'),
);
});
// 第一条被立刻放行:回合运行中,发送钮位置变成终止钮,队列里没有它(它不是待发消息)。
expect(
await within(surface).findByRole('button', { name: '终止' }),
).not.toBeNull();
expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull();
await setComposerText(composer, '第二条消息');
submitComposerForm(composer);
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('第三条消息'),
]);
// 单条取消:命令交给宿主判,界面按 `queue.removed{cancelled}` 撤掉那一条,顺序不变。
fireEvent.click(
within(queue).getByRole('button', { name: '取消待发消息 第二条消息' }),
);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'remove_direct_project_pending_turn',
),
).toHaveLength(1);
});
await waitFor(() => {
expect(within(queue).getAllByRole('listitem')).toHaveLength(1);
});
expect(queue.textContent ?? '').toContain('第三条消息');
// 回合收尾 → 宿主放行剩下的那条:chip 消失与它自己的回合开始在同一批事件里。
act(() => {
host?.complete();
});
await waitFor(() => {
expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull();
});
// 关键不变式:排队的那条**没有**再走一次发送命令,入队就是一次性动作。
expect(
invoke.mock.calls
.filter(([command]) => command === 'enqueue_direct_codex_turn')
.map(([, args]) => directTurnInputText(args)),
).toEqual(['第一条消息', '第二条消息', '第三条消息']);
});
it('tells the user when the chip they tried to cancel has already been dispatched', async () => {
// 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。
const { invoke, surface, harness } = await openDirectCodexSurface({
// 入队成立但并不放行(线程上已经有别的回合在跑):chip 留着,取消才有东西可取消。
enqueue_direct_codex_turn: (args) => {
harness.emitDirectThreadEvents({
type: 'queue.enqueued',
clientTurnId: String(args?.clientTurnId ?? ''),
userItem: args?.userItem,
at: 1,
});
return null;
},
remove_direct_project_pending_turn: () => 'alreadyDispatched',
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '还没跑的这条');
const queue = await within(surface).findByLabelText('待发送消息队列');
expect(within(queue).getByText('还没跑的这条')).not.toBeNull();
fireEvent.click(
within(queue).getByRole('button', { name: '取消待发消息 还没跑的这条' }),
);
await waitFor(() => {
expect(
within(surface).getByText(
'这条消息已经发出,不能按待发消息取消;要停下正在跑的这一轮请点「终止」',
),
).not.toBeNull();
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'remove_direct_project_pending_turn',
),
).toHaveLength(1);
});
it('keeps the accept window silent in the chat and busy in the composer', async () => {
const pending: Array<{ resolve: (value: string) => void }> = [];
let userItemId = '';
const { invoke, surface, harness } = await openDirectCodexSurface({
enqueue_direct_codex_turn: (args) => {
userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
return 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(
'enqueue_direct_codex_turn',
expectDirectTurnWithText('窗口期的消息'),
);
});
// 本地不再造乐观气泡:宿主既没回 turn.started、也没下发用户条目,聊天区里就没有这一轮,
// 也就不会有"已结束"的终态文案;窗口期的反馈只有 composer 的忙态与这张过程卡。
const conversation = within(surface).getByLabelText('陶泥儿消息');
expect(within(conversation).queryByText('窗口期的消息')).toBeNull();
expect(within(conversation).queryByText(/本轮结束于/)).toBeNull();
expect(within(conversation).queryByTestId('turn-usage')).toBeNull();
// 卡片从「本地命令在飞」起就得出现:只认原生 turn.started 的话,模型首 token 之前
// 那段界面完全不说"正在处理"。但起点还得等宿主给(`turn.started.at`),所以这一刻
// 卡片只报"正在处理"、不读秒。
expect(
within(surface).getAllByText('陶泥儿正在处理').length,
).toBeGreaterThan(0);
expect(within(surface).queryByText(/^已耗时 /u)).toBeNull();
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
expect(
within(surface).getByRole('button', { name: '终止' }),
).not.toBeNull();
// 宿主认领这一轮:开始事件 + 开口用户条目下发。用户那条消息这时才第一次出现在聊天区,
// 卡片也开始按宿主的起点读秒。
const startedAt = Date.now() - 1_500;
act(() => {
harness.emitDirectThreadEvents(
{ type: 'turn.started', at: startedAt, userItemId },
{
type: 'item.completed',
at: startedAt,
item: {
itemType: 'message',
itemId: userItemId,
role: 'user',
text: '窗口期的消息',
at: startedAt,
},
},
);
});
await waitFor(() => {
expect(within(conversation).getByText('窗口期的消息')).not.toBeNull();
});
await waitFor(() => {
expect(within(surface).getByText(/^已耗时 /u)).not.toBeNull();
});
await act(async () => {
pending[0]?.resolve('回复');
});
});
it('closes the turn from the host failure payload instead of leaving it running', async () => {
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
null;
const { surface } = await openDirectCodexSurface(
{
enqueue_direct_codex_turn: (
args: Record<string, unknown> | undefined,
) => {
// 宿主先认领了这一轮(turn.started),随后失败收场:失败原因由终态事件自己带出来,
// 命令也以失败返回(真实宿主是先 append 终态、再把错误抛回前端)。
const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
harness?.emitDirectThreadEvents(
{ type: 'turn.started', at: 5_000, userItemId },
{
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'request-rejected',
message: 'codex-app-server-error:context-window-exceeded',
},
at: 6_000,
userItemId,
},
);
throw new Error('模拟宿主失败:错误只走终态事件,命令只回报失败');
},
},
(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();
// 命令返回那条通道只负责横幅:它不写第二条聊天文案。真失败时 `runTurn` 会先把原始错误
// 映射成通用可见文案再走横幅(横幅在头部的状态行,不在会话列表里),所以这里查映射后的
// 文案有没有出现在会话列表里,而不是那条永远不会被渲染的原始错误文本。
const conversation = within(surface).getByLabelText('陶泥儿消息');
expect(
within(conversation).queryAllByText(
'陶泥儿智能创作 执行失败,请稍后重试',
),
).toHaveLength(0);
});
it('keeps the composer moving when the host fails the turn right after accepting it', async () => {
// 落盘失败发生在**放行之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个
// 失败)。这里钉住这条时序的界面结果:说明只来自事件、恰好一条,忙态被放掉,下一条还能发。
const seen: string[] = [];
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
null;
const { surface } = await openDirectCodexSurface(
{
enqueue_direct_codex_turn: (
args: Record<string, unknown> | undefined,
) => {
const text = directTurnInputText(args);
seen.push(text);
if (text === '落盘失败的那条') {
const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`;
harness?.emitDirectThreadEvents(
{ type: 'turn.started', at: 5_000, userItemId },
{
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'environment-not-ready',
message: '写入本项目对话历史失败:项目对话历史追加写失败',
},
at: 5_100,
userItemId,
},
);
}
return Promise.resolve(null);
},
},
(directHarness) => {
harness = directHarness;
},
);
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '落盘失败的那条');
const conversation = await within(surface).findByLabelText('陶泥儿消息');
// 说明来自 `turn.completed.failure`,经同一份可见文案映射;命令返回 `Ok` 不再写第二条。
await waitFor(() => {
expect(
within(conversation).getAllByText(
'陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试',
),
).toHaveLength(1);
});
// 忙态已放掉、也没卡成忙碌:下一条能直接发出去。
await waitFor(() => {
expect(
within(conversation).queryAllByText('陶泥儿正在处理'),
).toHaveLength(0);
});
await submitDirectTurn(surface, composer, '后面这条');
await waitFor(() => {
expect(seen).toEqual(['落盘失败的那条', '后面这条']);
});
});
it('does not fire a second enqueue while the first one is still in flight', async () => {
// 草稿要等命令的入队结果才知道能不能清,所以这一段提交是"在飞"的。这期间第二次提交必须被挡住:
// 同一份草稿并发出两条就是两条消息(宿主按 `clientTurnId` 幂等也救不了——第二次是新身份)。
const pending: Array<{ resolve: (value: null) => void }> = [];
const { invoke, surface } = await openDirectCodexSurface({
enqueue_direct_codex_turn: () =>
new Promise<null>((resolve) => {
pending.push({ resolve });
}),
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '在飞的那条');
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'enqueue_direct_codex_turn',
),
).toHaveLength(1);
});
submitComposerForm(composer);
expect(
invoke.mock.calls.filter(
([command]) => command === 'enqueue_direct_codex_turn',
),
).toHaveLength(1);
// 命令落地(入队成立):草稿清掉、IPC 在飞这一段结束,用户能接着发下一条。
await act(async () => {
pending[0]?.resolve(null);
});
await waitFor(() => {
expect(
within(surface).getByRole('button', { name: '发送' }),
).not.toBeNull();
});
});
it('只有待发消息、没有回合在跑时不显示终止钮', async () => {
// 队列非空但既没有在飞的命令、也没有在跑的回合(比如宿主还没认领队首):这时"终止"没有作用
// 对象,界面不该给一个点了只会报「当前没有正在运行的回合」的按钮。
const { surface, harness } = await openDirectCodexSurface({
enqueue_direct_codex_turn: (args) => {
harness.emitDirectThreadEvents({
type: 'queue.enqueued',
clientTurnId: String(args?.clientTurnId ?? ''),
userItem: args?.userItem,
at: 1,
});
return null;
},
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '排着的那条');
const queue = await within(surface).findByLabelText('待发送消息队列');
await waitFor(() => {
expect(within(queue).getByText('排着的那条')).not.toBeNull();
});
// 队列非空仍然是"忙"(卡片照旧说正在处理),但发送入口是发送钮:用户可以继续排。
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
expect(
await within(surface).findByRole('button', { name: '发送' }),
).not.toBeNull();
});
it('queues the next message on the host while a turn is already running', async () => {
// 回合已经在跑(比如刚切回工作台):这时发送只做一件事——交给宿主入队,chip 由事件投影出来。
let host: ReturnType<typeof hostQueue> | null = null;
const { invoke, surface, harness } = await openDirectCodexSurface(
{ enqueue_direct_codex_turn: (args) => host?.enqueue(args) },
(directHarness) => {
directHarness.emitDirectThreadEvents({ type: 'turn.started' });
},
);
// 线程上已经有回合在跑:入队成立但不放行,chip 就留着。
host = hostQueue(harness, { running: true });
const composer = within(surface).getByLabelText('陶泥儿对话内容');
expect(
await within(surface).findByRole('button', { name: '终止' }),
).not.toBeNull();
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
// 回合运行中发送钮位置是终止钮,所以这里走表单提交(Enter 也是这条路)。
await setComposerText(composer, '排在后面的消息');
submitComposerForm(composer);
const queue = await within(surface).findByLabelText('待发送消息队列');
await waitFor(() => {
expect(within(queue).getByText('排在后面的消息')).not.toBeNull();
});
// 入队就是一次性动作:这条消息只有这一次命令,没有"先存本地、之后由这个窗口再发一次"。
expect(
invoke.mock.calls.filter(
([command]) => command === 'enqueue_direct_codex_turn',
),
).toHaveLength(1);
// 宿主在回合收尾后自己放行队首:chip 消失,那一轮的回合开始事件同批到达。
act(() => {
host?.complete();
});
await waitFor(() => {
expect(within(surface).queryByLabelText('待发送消息队列')).toBeNull();
});
expect(
invoke.mock.calls.filter(
([command]) => command === 'enqueue_direct_codex_turn',
),
).toHaveLength(1);
expect(
await within(surface).findByRole('button', { name: '终止' }),
).not.toBeNull();
});
it('terminates the running turn and returns the composer to the idle state', async () => {
const { invoke, path, surface, harness } = await openDirectCodexSurface({
// 命令只入队:入队成立(放行与开始事件都由 Thread Manager 自己完成)后它立刻返回,"这一轮还在跑"
// 由订阅事件回答——所以忙碌态与「终止」入口都不再依赖命令 promise 还悬着。
enqueue_direct_codex_turn: () => {
harness.emitDirectThreadEvents({ type: 'turn.started' });
return Promise.resolve(null);
},
cancel_direct_codex_turn: async () => ({
outcome: 'interrupted',
message: '已向正在运行的回合发出终止',
clientTurnId: 'direct-turn-cancel',
}),
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '做一个小游戏');
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'enqueue_direct_codex_turn',
expectDirectTurnWithText('做一个小游戏'),
);
});
const stopButton = await within(surface).findByRole('button', {
name: '终止',
});
fireEvent.click(stopButton);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', {
projectPath: path,
});
});
// 用户点「终止」的可读反馈走 composer 提示;这一轮怎么收场只由终态事件回答。
act(() => {
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 === 'enqueue_direct_codex_turn',
),
).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();
}
});
}