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 重试的正文重置语义 补充重复终态、整块回复、多消息及回合隔离回归测试 同步更新策划展示规范与排障记录
904 lines
32 KiB
TypeScript
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('思考过程'));
|
|
});
|
|
}
|