Files
lhk229 688cf7a971
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m59s
Project CI / Backend tests (pull_request) Successful in 5m1s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m7s
Project CI / Native shell tests (pull_request) Successful in 6m17s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m29s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m42s
Project CI / Repository checks (pull_request) Successful in 2m18s
修复策划回复重复播放并保留伪流式
按消息 ID 合并实时正文与正式消息,保留逐步显示进度
分离请求收尾与动画结束,隔离旧回合事件及异步返回
独立展示工具状态,保留 Provider 重试的正文重置语义
补充重复终态、整块回复、多消息及回合隔离回归测试
同步更新策划展示规范与排障记录
2026-09-23 10:38:01 +00:00

904 lines
32 KiB
TypeScript

import {
act,
App,
createProjectChatRuntimeHarness,
expect,
fireEvent,
it,
React,
render,
screen,
setComposerText,
waitFor,
} from './harness';
function renderDesignAgent(
harness: ReturnType<typeof createProjectChatRuntimeHarness>,
) {
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
return render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
planningStartMode: true,
}),
);
}
async function expectDesignModelReady() {
await waitFor(() => {
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('高质量');
});
}
function designApprovalView() {
return {
session: {
sessionId: 'design-session-1',
projectId: 'local-project-draft',
currentPhase: 'concept',
approvedPhases: [],
pendingApproval: {
requestId: 'approval-1',
phase: 'concept',
submittedAt: 1,
},
pendingClarification: null,
turnIndex: 2,
lastError: null,
},
messages: [{ id: 'a1', role: 'assistant', text: '概念稿已写好。' }],
running: false,
canRetry: false,
};
}
function designClarificationView() {
return {
session: {
sessionId: 'design-session-clarification',
projectId: 'local-project-draft',
currentPhase: 'concept',
approvedPhases: [],
pendingApproval: null,
pendingClarification: {
requestId: 'clarification-1',
question: '主要面向哪个平台?',
options: ['PC', '移动端'],
createdAt: 1,
},
turnIndex: 1,
lastError: null,
},
messages: [],
running: false,
canRetry: false,
};
}
function designConversationView() {
return {
session: {
sessionId: 'design-session-reasoning',
projectId: 'local-project-draft',
currentPhase: 'concept',
approvedPhases: [],
pendingApproval: null,
pendingClarification: null,
turnIndex: 1,
lastError: null,
},
messages: [],
running: false,
canRetry: false,
};
}
function designHistoryView() {
return {
...designConversationView(),
messages: [
{ id: 'assistant-1', role: 'assistant', text: '第一轮正文' },
{ id: 'assistant-2', role: 'assistant', text: '第二轮正文' },
],
reasoningEntries: [
{
id: 'reasoning-1',
messageId: 'assistant-1',
text: '第一轮思考',
},
{
id: 'reasoning-2',
messageId: 'assistant-2',
text: '第二轮思考',
},
],
};
}
export function registerDesignAgentSurfaceTests() {
it('imports planning chat files into the workspace and retries failed files without turn attachments', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
designAgentContinueView: designConversationView(),
});
const originalInvoke = harness.invoke.getMockImplementation()!;
let failImport = true;
let finishImport!: () => void;
const importGate = new Promise<void>((resolve) => {
finishImport = resolve;
});
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'import_design_workspace_file') {
await importGate;
if (args?.fileName === '补充.md' && failImport)
throw new Error('磁盘写入失败');
return `references/${args?.fileName}`;
}
return originalInvoke(command, args);
});
renderDesignAgent(harness);
await expectDesignModelReady();
const button = screen.getByRole('button', { name: '导入文件到策划工作区' });
expect(button).not.toBeNull();
const input = document.querySelector<HTMLInputElement>(
'[data-chat-composer-upload]',
)!;
const files = ['需求.md', '补充.md'].map((name) => {
const file = new File(['# 参考'], name, { type: 'text/markdown' });
Object.defineProperty(file, 'arrayBuffer', {
value: async () => new Uint8Array([35, 32, 65]).buffer,
});
return file;
});
const manifestReads = () =>
harness.invoke.mock.calls.filter(
([command]) => command === 'get_local_game_manifest',
).length;
const readsBefore = manifestReads();
fireEvent.change(input, { target: { files } });
await screen.findByText('正在导入文件');
const composer = screen.getByLabelText('项目需求');
await setComposerText(composer, '阅读工作区文件');
expect(
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(true);
fireEvent.submit(composer.closest('form')!);
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'continue_design_agent_session',
),
).toBe(false);
await act(async () => finishImport());
await screen.findByText(
'已导入 1 个文件到策划工作区;1 个文件导入失败:补充.md:磁盘写入失败。可重新选择失败文件重试。',
);
expect(harness.invoke).toHaveBeenCalledWith(
'import_design_workspace_file',
{
projectPath: harness.projectPath,
fileName: '需求.md',
bytes: [35, 32, 65],
},
);
expect(manifestReads()).toBe(readsBefore);
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'upload_local_asset',
),
).toBe(false);
expect(screen.queryByLabelText('待发送附件')).toBeNull();
failImport = false;
fireEvent.change(input, { target: { files: [files[1]] } });
await screen.findByText('已导入 1 个文件到策划工作区');
expect(
harness.invoke.mock.calls.filter(
([command]) => command === 'import_design_workspace_file',
),
).toHaveLength(3);
await setComposerText(composer, '阅读工作区文件');
fireEvent.submit(composer.closest('form')!);
await waitFor(() =>
expect(harness.invoke).toHaveBeenCalledWith(
'continue_design_agent_session',
expect.objectContaining({
input: { type: 'message', text: '阅读工作区文件' },
}),
),
);
const turn = harness.invoke.mock.calls.find(
([command]) => command === 'continue_design_agent_session',
);
expect(JSON.stringify(turn?.[1])).not.toContain('references/');
expect(turn?.[1]).not.toHaveProperty('attachments');
});
it('waits for planning file imports before allowing phase approval', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designApprovalView(),
});
const originalInvoke = harness.invoke.getMockImplementation()!;
harness.invoke.mockImplementation(async (command, args) =>
command === 'import_design_workspace_file'
? 'references/需求.md'
: originalInvoke(command, args),
);
renderDesignAgent(harness);
const approve = await screen.findByRole('button', { name: '批准' });
const file = new File(['# 参考'], '需求.md');
let finishRead!: (value: ArrayBuffer) => void;
Object.defineProperty(file, 'arrayBuffer', {
value: () =>
new Promise<ArrayBuffer>((resolve) => {
finishRead = resolve;
}),
});
fireEvent.change(document.querySelector('[data-chat-composer-upload]')!, {
target: { files: [file] },
});
await screen.findByText('正在导入文件');
expect(approve.hasAttribute('disabled')).toBe(true);
fireEvent.click(approve);
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'decide_design_phase',
),
).toBe(false);
await act(async () => finishRead(new Uint8Array([65]).buffer));
await screen.findByText('已导入 1 个文件到策划工作区');
expect(approve.hasAttribute('disabled')).toBe(false);
});
it('persists Design Agent model and reasoning controls and reads them on reentry', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
});
const first = renderDesignAgent(harness);
await expectDesignModelReady();
const reasoning = screen.getByRole('button', { name: '推理档' });
expect(reasoning.closest('.project-chat-composer-controls')).not.toBeNull();
fireEvent.click(reasoning);
fireEvent.click(screen.getByRole('option', { name: '低' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith(
'select_game_creator_reasoning_effort',
{ effort: 'low' },
);
expect(
screen.getByRole('button', { name: '推理档' }).textContent,
).toContain('低');
});
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(await screen.findByRole('option', { name: '快速' }));
await waitFor(() => {
expect(harness.invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
isDefault: false,
});
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
first.unmount();
renderDesignAgent(harness);
await waitFor(() => {
expect(
screen.getByRole('button', { name: '推理档' }).textContent,
).toContain('低');
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'continue_design_agent_session',
),
).toBe(false);
});
it('allows Design Agent model changes while running without submitting a new turn', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: { ...designConversationView(), running: true },
});
renderDesignAgent(harness);
await expectDesignModelReady();
expect(
(screen.getByRole('button', { name: '推理档' }) as HTMLButtonElement)
.disabled,
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(await screen.findByRole('option', { name: '快速' }));
await waitFor(() => {
expect(
screen.getByRole('button', { name: '对话模型' }).textContent,
).toContain('快速');
});
const input = screen.getByLabelText('项目需求');
expect(input.closest('[data-disabled="true"]')).not.toBeNull();
const send = screen.getByRole('button', { name: '思考中' });
expect(send).toHaveProperty('disabled', true);
fireEvent.click(send);
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'continue_design_agent_session',
),
).toBe(false);
});
it('shows Design Agent reasoning save failures and preserves the saved selection', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
});
const invoke = harness.invoke.getMockImplementation()!;
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'select_game_creator_reasoning_effort') {
throw new Error('save failed');
}
return invoke(command, args);
});
renderDesignAgent(harness);
await expectDesignModelReady();
const reasoning = screen.getByRole('button', { name: '推理档' });
await waitFor(() => expect(reasoning.textContent).toContain('高'));
fireEvent.click(reasoning);
fireEvent.click(screen.getByRole('option', { name: '低' }));
await screen.findByText('推理档保存失败');
expect(reasoning.textContent).toContain('高');
});
it('resumes following Design Agent messages when sending after scrolling up', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designHistoryView(),
designAgentContinueView: {
...designHistoryView(),
messages: [
...designHistoryView().messages,
{ id: 'user-3', role: 'user', text: '补充多人玩法' },
{ id: 'assistant-3', role: 'assistant', text: '已补充多人玩法。' },
],
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
planningStartMode: true,
}),
);
await screen.findByText('第二轮正文');
const messageList = screen.getByLabelText('立项策划消息');
Object.defineProperty(messageList, 'scrollHeight', {
configurable: true,
value: 1000,
});
Object.defineProperty(messageList, 'clientHeight', {
configurable: true,
value: 200,
});
messageList.scrollTop = 120;
fireEvent.scroll(messageList);
const input = screen.getByLabelText('项目需求');
await setComposerText(input, '补充多人玩法');
expect(messageList.scrollTop).toBe(120);
fireEvent.submit(input.closest('form') as HTMLFormElement);
await screen.findByText('已补充多人玩法。');
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
expect(harness.invoke).toHaveBeenCalledWith(
'continue_design_agent_session',
expect.objectContaining({
input: { type: 'message', text: '补充多人玩法' },
}),
);
});
it('keeps one Design Agent reply when repeated completion arrives after typing catches up', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
});
const originalInvoke = harness.invoke.getMockImplementation()!;
let finish!: (view: unknown) => void;
harness.invoke.mockImplementation((command, args) =>
command === 'continue_design_agent_session'
? new Promise((resolve) => {
finish = resolve;
})
: originalInvoke(command, args),
);
renderDesignAgent(harness);
const input = await screen.findByLabelText('项目需求');
await expectDesignModelReady();
await setComposerText(input, '只回复 pong');
fireEvent.submit(input.closest('form') as HTMLFormElement);
await waitFor(() => expect(finish).toBeDefined());
const call = harness.invoke.mock.calls.find(
([command]) => command === 'continue_design_agent_session',
)!;
const clientTurnId = String(
(call[1] as { clientTurnId: string }).clientTurnId,
);
const messageId = `${clientTurnId}:response:0`;
const view = {
...designConversationView(),
messages: [{ id: messageId, role: 'assistant', text: 'pong' }],
};
const emit = (payload: Record<string, unknown>) =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
...payload,
});
act(() => emit({ kind: 'text', messageId, text: 'pong' }));
await screen.findByText('pong');
act(() => emit({ kind: 'state', view }));
act(() => emit({ kind: 'state', view }));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
expect(screen.getAllByText('pong')).toHaveLength(1);
expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull();
await act(async () => finish(view));
expect(screen.getAllByText('pong')).toHaveLength(1);
expect(
harness.invoke.mock.calls.filter(
([command]) => command === 'continue_design_agent_session',
),
).toHaveLength(1);
});
for (const firstDelivery of ['event', 'command'] as const) {
it(`animates a whole Design Agent reply once when ${firstDelivery} completes first`, async () => {
const history = { id: 'history', role: 'assistant', text: '历史回复' };
const harness = createProjectChatRuntimeHarness({
designAgentView: { ...designConversationView(), messages: [history] },
});
const originalInvoke = harness.invoke.getMockImplementation()!;
const requests: {
clientTurnId: string;
finish: (view: unknown) => void;
}[] = [];
harness.invoke.mockImplementation((command, args) =>
command === 'continue_design_agent_session'
? new Promise((resolve) => {
requests.push({
clientTurnId: String(args?.clientTurnId),
finish: resolve,
});
})
: originalInvoke(command, args),
);
renderDesignAgent(harness);
await screen.findByText(history.text);
await expectDesignModelReady();
const input = screen.getByLabelText('项目需求');
await setComposerText(input, '继续');
fireEvent.submit(input.closest('form')!);
await waitFor(() => expect(requests).toHaveLength(1));
const request = requests[0];
const messageId = `${request.clientTurnId}:response:0`;
const reply = '整块返回也逐步显示';
const terminal = {
...designConversationView(),
messages: [history, { id: messageId, role: 'assistant', text: reply }],
};
const emit = (payload: Record<string, unknown>) =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId: request.clientTurnId,
...payload,
});
if (firstDelivery === 'event')
act(() => emit({ kind: 'state', view: terminal }));
else await act(async () => request.finish(terminal));
const bubble = screen.getByLabelText('策划 Agent 实时回复');
expect(bubble.textContent).not.toBe(reply);
expect(screen.getAllByText(history.text)).toHaveLength(1);
expect(
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(false);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
const prefix = bubble.textContent;
expect(prefix?.length).toBeGreaterThan(0);
act(() => {
emit({ kind: 'state', view: terminal });
emit({ kind: 'state', view: terminal });
emit({ kind: 'text', messageId, text: '' });
});
expect(bubble.textContent).toBe(prefix);
await screen.findByText(reply);
expect(screen.getByText(reply).closest('.message')).toBe(bubble);
expect(
document.querySelectorAll(`[data-message-id="${messageId}"]`),
).toHaveLength(1);
expect(screen.queryByLabelText('策划 Agent 实时回复')).toBeNull();
// 用户可在旧 invoke 尚未返回时开始下一轮;旧 finally 不能解锁新回合。
await setComposerText(input, '下一轮');
fireEvent.submit(input.closest('form')!);
await waitFor(() => expect(requests).toHaveLength(2));
act(() => {
emit({ kind: 'text', messageId: 'stale', text: '迟到旧回复' });
emit({ kind: 'state', view: terminal });
});
await act(async () => request.finish(terminal));
expect(
screen.getByRole('button', { name: '思考中' }).hasAttribute('disabled'),
).toBe(true);
expect(screen.queryByText('迟到旧回复')).toBeNull();
await act(async () => requests[1].finish(terminal));
});
}
it('follows streamed Design Agent content until the user scrolls up', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
designAgentContinueView: designConversationView(),
});
const originalInvoke = harness.invoke.getMockImplementation()!;
let finish!: () => void;
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'continue_design_agent_session') await gate;
return originalInvoke(command, args);
});
renderDesignAgent(harness);
const input = await screen.findByLabelText('项目需求');
await setComposerText(input, '请继续完善方案');
fireEvent.submit(input.closest('form') as HTMLFormElement);
await waitFor(() =>
expect(harness.invoke).toHaveBeenCalledWith(
'continue_design_agent_session',
expect.objectContaining({
input: { type: 'message', text: '请继续完善方案' },
}),
),
);
const continueCall = [...harness.invoke.mock.calls]
.reverse()
.find(([command]) => command === 'continue_design_agent_session');
const clientTurnId = String(
(continueCall?.[1] as { clientTurnId?: string }).clientTurnId,
);
const messageList = screen.getByLabelText('立项策划消息');
Object.defineProperty(messageList, 'scrollHeight', {
configurable: true,
value: 1000,
});
Object.defineProperty(messageList, 'clientHeight', {
configurable: true,
value: 200,
});
messageList.scrollTop = 800;
fireEvent.scroll(messageList);
act(() =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
reasoningText: '先核对玩法循环',
}),
);
await screen.findAllByText('先核对玩法循环');
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
messageList.scrollTop = 800;
fireEvent.scroll(messageList);
act(() =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'text',
messageId: `${clientTurnId}:response:0`,
text: '正在补充关卡节奏',
}),
);
await screen.findByLabelText('策划 Agent 实时回复');
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
const replyBeforeTool = screen.getByLabelText('策划 Agent 实时回复');
const prefixBeforeTool = replyBeforeTool.textContent;
act(() =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'tool',
text: '正在读取方案文件',
}),
);
expect(screen.getByLabelText('策划 Agent 工具状态').textContent).toBe(
'正在读取方案文件',
);
expect(replyBeforeTool.textContent).toBe(prefixBeforeTool);
messageList.scrollTop = 120;
fireEvent.scroll(messageList);
act(() => {
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
reasoningText: '继续检查多人规则',
});
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'text',
messageId: `${clientTurnId}:response:0`,
text: '正在补充关卡节奏与多人规则',
});
});
await screen.findAllByText('继续检查多人规则');
await waitFor(() =>
expect(
screen.getByLabelText('策划 Agent 实时回复').textContent,
).toContain('多人规则'),
);
expect(messageList.scrollTop).toBe(120);
await act(async () => finish());
});
it('shows only known optimistic send times and does not invent persisted times', async () => {
const hydratedView = {
...designConversationView(),
messages: [{ id: 'user-history', role: 'user', text: '历史策划需求' }],
};
const continuedView = {
...designConversationView(),
messages: [
...hydratedView.messages,
{ id: 'user-current', role: 'user', text: '当前补充需求' },
],
};
const harness = createProjectChatRuntimeHarness({
designAgentView: hydratedView,
designAgentContinueView: continuedView,
});
const originalInvoke = harness.invoke.getMockImplementation()!;
let finishContinue!: () => void;
const continueGate = new Promise<void>((resolve) => {
finishContinue = resolve;
});
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'continue_design_agent_session') {
await continueGate;
}
return originalInvoke(command, args);
});
renderDesignAgent(harness);
const historicalText = await screen.findByText('历史策划需求');
expect(
historicalText.closest('.message')?.querySelector('time'),
).toBeNull();
const input = screen.getByLabelText('项目需求');
await setComposerText(input, '当前补充需求');
fireEvent.submit(input.closest('form') as HTMLFormElement);
const optimisticText = await screen.findByText('当前补充需求');
const optimisticTime = optimisticText
.closest('.message')
?.querySelector('time');
expect(optimisticTime).not.toBeNull();
expect(optimisticTime?.getAttribute('dateTime')).toBeTruthy();
await act(async () => finishContinue());
await waitFor(() => {
const currentText = screen.getByText('当前补充需求');
expect(currentText.closest('.message')?.querySelector('time')).toBeNull();
});
expect(
screen
.getByText('历史策划需求')
.closest('.message')
?.querySelector('time'),
).toBeNull();
});
it('hydrates an existing design session and decides approval through design commands', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designApprovalView(),
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
planningStartMode: true,
}),
);
await screen.findByLabelText('批准');
expect(screen.queryByLabelText('项目总控 Agent 状态')).toBeNull();
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'hydrate_design_agent_session',
),
).toBe(true);
// 输入区在本分支已换成 Lexical 富文本 `ResourceReferenceInput`:禁用不再落在
// 这只 div 的 `disabled` 属性上(div 没有该属性),而是容器 `data-disabled` +
// `editor.setEditable(false)`。断言意图不变:审批待定时对话输入不可用。
expect(
screen.getByLabelText('项目需求').closest('[data-disabled="true"]'),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '批准' }));
await waitFor(() => {
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'decide_design_phase',
),
).toBe(true);
});
});
it('submits clarification free text without selecting an option', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designClarificationView(),
designAgentContinueView: {
...designClarificationView(),
session: {
...designClarificationView().session,
pendingClarification: null,
},
},
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
planningStartMode: true,
}),
);
const answer = await screen.findByPlaceholderText('或者直接输入你的回答');
fireEvent.change(answer, { target: { value: 'PC 和主机平台都要支持' } });
fireEvent.click(screen.getByRole('button', { name: '提交回答' }));
await waitFor(() => {
expect(
harness.invoke.mock.calls.some(
([command, args]) =>
command === 'continue_design_agent_session' &&
(args as { input?: Record<string, unknown> }).input?.type ===
'clarification' &&
(args as { input?: Record<string, unknown> }).input?.requestId ===
'clarification-1' &&
(args as { input?: Record<string, unknown> }).input?.optionIndex ===
null &&
(args as { input?: Record<string, unknown> }).input?.text ===
'PC 和主机平台都要支持',
),
).toBe(true);
});
});
it('keeps the current turn reasoning after completion and supports collapse/expand', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
designAgentContinueView: designConversationView(),
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
planningStartMode: true,
}),
);
const input = await screen.findByLabelText('项目需求');
await setComposerText(input, '请给出核心玩法方案');
fireEvent.submit(input.closest('form') as HTMLFormElement);
await waitFor(() => {
expect(
harness.invoke.mock.calls.some(
([command]) => command === 'continue_design_agent_session',
),
).toBe(true);
});
const continueCall = [...harness.invoke.mock.calls]
.reverse()
.find(([command]) => command === 'continue_design_agent_session');
const clientTurnId = String(
(continueCall?.[1] as { clientTurnId?: string }).clientTurnId,
);
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
reasoningText:
'## 结论\n\n- 先分析需求\n- 再组织方案\n\n用 `npm run build` 验证',
});
// 折叠入口按 aria-label 定位(标题文案不再写死,折叠态显示的是单行预览)。
const details = (await screen.findByLabelText(
/思考过程/,
)) as HTMLDetailsElement;
const summary = details.querySelector('summary') as HTMLElement;
expect(details.getAttribute('data-agent-content')).toBe('process');
expect(details.open).toBe(false);
// 折叠态是纯文本单行预览:Markdown 符号不出现在入口文字里。
expect(summary.textContent).toContain('结论');
expect(summary.textContent).not.toContain('##');
expect(summary.textContent).not.toContain('`');
expect(summary.querySelector('.lucide-lightbulb')).not.toBeNull();
expect(summary.querySelector('.lucide-brain')).toBeNull();
fireEvent.click(summary);
expect(details.open).toBe(true);
// 展开态复用助手正文的 Markdown 安全链路:标题 / 列表 / 行内代码都成为真实语义元素。
await waitFor(() => {
expect(details.querySelector('h2')?.textContent).toBe('结论');
expect(summary.textContent).toBe('思考过程');
});
expect(details.querySelectorAll('li')).toHaveLength(2);
expect(details.querySelector('code')?.textContent).toBe('npm run build');
expect(details.textContent?.match(/结论/g)).toHaveLength(1);
fireEvent.click(summary);
await waitFor(() => {
expect(details.open).toBe(false);
expect(summary.textContent).toContain('结论');
});
});
it('renders historical reasoning as independent collapsed sections', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designHistoryView(),
});
window.__TAURI__ = {
core: { invoke: harness.invoke },
event: { listen: harness.listen },
};
window.history.pushState({}, '', '/');
render(
React.createElement(App, {
initialProjectPath: harness.projectPath,
planningStartMode: true,
}),
);
const details = (await screen.findAllByLabelText(
/思考过程/,
)) as HTMLDetailsElement[];
expect(details).toHaveLength(2);
const summaries = details.map(
(element) => element.querySelector('summary') as HTMLElement,
);
expect(details.every((element) => !element.open)).toBe(true);
expect(
details.every(
(element) => element.getAttribute('data-agent-content') === 'process',
),
).toBe(true);
fireEvent.click(summaries[0]);
expect(details[0].open).toBe(true);
expect(details[1].open).toBe(false);
await waitFor(() => expect(summaries[0].textContent).toBe('思考过程'));
});
}