Files
makelore/tests/unit/coding-conversation-timeline.test.tsx

969 lines
40 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 { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { Profiler } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
const attachmentApi = vi.hoisted(() => ({ load: vi.fn() }));
vi.mock('@/lib/coding-attachments', () => ({
loadCodingAttachmentPreview: attachmentApi.load,
}));
describe('CodingConversationTimeline', () => {
afterEach(() => vi.unstubAllGlobals());
it('resolves attachment refs into temporary object URLs without base64 state', async () => {
const createObjectURL = vi.fn(() => 'blob:authenticated-preview');
const revokeObjectURL = vi.fn();
class TestURL extends URL {}
TestURL.createObjectURL = createObjectURL;
TestURL.revokeObjectURL = revokeObjectURL;
vi.stubGlobal('URL', TestURL);
attachmentApi.load.mockResolvedValue({
bytes: new Uint8Array([1, 2, 3]),
mime: 'image/png',
});
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-attachment', 1);
const snapshot = {
...base,
nodes: [{
kind: 'message' as const,
id: 'message-1',
role: 'user' as const,
status: 'complete' as const,
blocks: [{
kind: 'image' as const,
id: 'image-1',
attachmentId: 'attachment-1',
mime: 'image/png',
}],
}],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-attachment',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
const view = render(
<CodingConversationTimeline conversationId="conversation-attachment" />,
);
await waitFor(() => expect(screen.getByRole('img', { name: '对话图片附件' }))
.toHaveAttribute('src', 'blob:authenticated-preview'));
expect(attachmentApi.load).toHaveBeenCalledWith('attachment-1');
expect(createObjectURL).toHaveBeenCalledOnce();
const blob = createObjectURL.mock.calls[0][0] as Blob;
expect(blob.type).toBe('image/png');
expect(JSON.stringify(codingConversationStore.getState())).not.toContain('base64');
view.unmount();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:authenticated-preview');
});
it('renders assistant Markdown hierarchy and opens web links and local paths safely', async () => {
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);
invoke.mockReset();
invoke.mockImplementation(async (channel) => (
channel === 'app:getPath' ? '/Users/tester' : undefined
));
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-markdown-layout', 1);
const snapshot = {
...base,
nodes: [{
kind: 'message' as const,
id: 'message-markdown-layout',
role: 'assistant' as const,
status: 'complete' as const,
stopReason: 'stop' as const,
blocks: [{
kind: 'text' as const,
id: 'text-markdown-layout',
status: 'complete' as const,
text: [
'# 网站完成',
'',
'## 文件位置',
'',
'`~/Desktop/123/guiyang/index.html`',
'',
'## 查看方式',
'',
'- 直接打开 `guiyang/index.html`',
'- 本地服务 `http://localhost:8642/index.html`',
'',
'## 页面内容',
'',
'| 区块 | 内容 |',
'| --- | --- |',
'| Hero 首屏 | 渐变主题与城市标语 |',
'| 城市概况 | 面积与人口数据 |',
'',
'- 平滑滚动',
'- 响应式布局',
'',
'[项目文档](https://example.com/docs)',
'',
'https://example.org/guide',
'',
'[不安全链接](file:///tmp/private.txt)',
].join('\n'),
}],
}],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-markdown-layout',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(<CodingConversationTimeline conversationId="conversation-markdown-layout" />);
expect(screen.getByTestId('assistant-markdown')).toHaveClass('text-foreground');
expect(screen.getByTestId('assistant-markdown')).not.toHaveClass('text-muted-foreground');
expect(screen.getByRole('heading', { level: 1, name: '网站完成' }))
.toHaveClass('text-xl');
expect(screen.getByRole('heading', { level: 2, name: '文件位置' }))
.toHaveClass('text-lg');
expect(screen.getByText('平滑滚动').closest('ul')).toHaveClass('list-disc');
expect(screen.getByRole('table').parentElement).toHaveClass('overflow-x-auto');
expect(screen.getByRole('columnheader', { name: '区块' })).toBeVisible();
expect(screen.getByRole('cell', { name: '渐变主题与城市标语' })).toBeVisible();
const markdownLink = screen.getByRole('link', { name: '项目文档' });
expect(markdownLink).toHaveAttribute('href', 'https://example.com/docs');
expect(markdownLink).toHaveClass('text-brand');
expect(markdownLink.className).not.toMatch(/\bbg-/);
fireEvent.click(markdownLink);
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
'shell:openExternal',
'https://example.com/docs',
));
fireEvent.contextMenu(markdownLink);
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
'shell:showLinkContextMenu',
{ kind: 'external', target: 'https://example.com/docs' },
));
expect(screen.getByRole('link', { name: 'https://example.org/guide' }))
.toHaveAttribute('href', 'https://example.org/guide');
const inlineCodeLink = screen.getByRole('link', {
name: 'http://localhost:8642/index.html',
});
expect(inlineCodeLink).toHaveAttribute('href', 'http://localhost:8642/index.html');
expect(inlineCodeLink).toHaveClass('text-brand');
expect(inlineCodeLink.className).not.toMatch(/\bbg-/);
fireEvent.click(inlineCodeLink);
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
'shell:openExternal',
'http://localhost:8642/index.html',
));
fireEvent.contextMenu(inlineCodeLink);
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
'shell:showLinkContextMenu',
{ kind: 'external', target: 'http://localhost:8642/index.html' },
));
expect(screen.queryByRole('link', { name: '不安全链接' })).not.toBeInTheDocument();
expect(screen.getByText('不安全链接')).toBeVisible();
const absoluteLocalLink = screen.getByRole('button', {
name: '用默认浏览器打开 ~/Desktop/123/guiyang/index.html',
});
expect(absoluteLocalLink).toHaveClass('text-brand');
expect(absoluteLocalLink.className).not.toMatch(/\bbg-/);
fireEvent.click(absoluteLocalLink);
await waitFor(() => expect(invoke).toHaveBeenCalledWith('app:getPath', 'home'));
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
'shell:openPath',
'/Users/tester/Desktop/123/guiyang/index.html',
));
fireEvent.contextMenu(absoluteLocalLink);
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
'shell:showLinkContextMenu',
{ kind: 'local-web', target: '~/Desktop/123/guiyang/index.html' },
));
const relativeLocalLink = screen.getByRole('button', {
name: '用默认浏览器打开 guiyang/index.html',
});
expect(relativeLocalLink).toHaveClass('text-brand');
expect(relativeLocalLink.className).not.toMatch(/\bbg-/);
fireEvent.click(relativeLocalLink);
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
'shell:openPath',
'/Users/tester/Desktop/123/guiyang/index.html',
));
fireEvent.contextMenu(relativeLocalLink);
await waitFor(() => expect(invoke).toHaveBeenCalledWith(
'shell:showLinkContextMenu',
{ kind: 'local-web', target: '~/Desktop/123/guiyang/index.html' },
));
expect(invoke).not.toHaveBeenCalledWith(
'shell:showItemInFolder',
expect.anything(),
);
});
it('streams thinking and tool progress through one-line previews with expandable details', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-streaming-process', 1);
const initialThinkingText = [
'先确认 `project.json` 项目边界,再读取配置。',
'然后核对入口文件与运行命令。',
'最后确认本轮只读取、不修改项目内容。',
].join('\n\n');
const initialNoteText = [
'好的,两件事都办:先再试一次产品素材服务,然后整体优化手机移动端体验。',
'让我看看 `index.html` 当前的实际内容:',
].join('\n\n');
const initialToolOutput = '已定位项目根目录\n正在读取入口文件…';
const createStreamingSnapshot = (
seq: number,
thinkingText: string,
noteText: string,
toolOutput: string,
) => ({
...base,
cursor: { ...base.cursor, seq },
nodes: [
{
kind: 'message' as const,
id: 'message-streaming-process',
role: 'assistant' as const,
status: 'streaming' as const,
blocks: [
{
kind: 'thinking' as const,
id: 'thinking-streaming-process',
text: thinkingText,
status: 'streaming' as const,
},
{
kind: 'text' as const,
id: 'preamble-streaming-process',
text: noteText,
status: 'complete' as const,
},
],
},
{
kind: 'tool' as const,
id: 'tool-streaming-process',
toolCallId: 'call-streaming-process',
toolName: 'read_file',
title: '读取项目配置',
inputText: 'project.json',
status: 'running' as const,
output: [{
kind: 'text' as const,
id: 'tool-output-streaming-process',
text: toolOutput,
status: 'streaming' as const,
}],
},
],
});
const snapshot = createStreamingSnapshot(
base.cursor.seq,
initialThinkingText,
initialNoteText,
initialToolOutput,
);
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-streaming-process',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(
<CodingConversationTimeline
conversationId="conversation-streaming-process"
assistantName="Builder"
/>,
);
const process = screen.getByTestId('coding-process-group');
expect(process).toHaveAttribute('open');
expect(within(process).getByText('处理中')).toBeVisible();
const thinking = screen.getByLabelText('思考过程');
const thinkingPreview = within(thinking).getByTestId('process-progress-preview');
expect(thinkingPreview).toHaveTextContent('最后确认本轮只读取、不修改项目内容。');
expect(thinkingPreview).toHaveAttribute('data-progress-tail', 'true');
expect(thinkingPreview).toHaveAttribute('data-progress-alignment', 'left');
expect(thinkingPreview).toHaveAttribute('data-progress-update-motion', 'none');
expect(thinkingPreview).not.toHaveAttribute('data-roll-revision');
expect(thinkingPreview).toHaveAttribute('data-progress-shimmer', 'true');
expect(thinkingPreview).toHaveClass('text-muted-foreground/75');
expect(thinkingPreview.querySelector('.streaming-progress-shimmer')).toBeInTheDocument();
expect(thinkingPreview.querySelector('.streaming-progress-roll')).not.toBeInTheDocument();
expect(thinkingPreview).toHaveClass('text-left');
expect(thinkingPreview.firstElementChild).toHaveClass('inline-flex');
expect(thinkingPreview.firstElementChild).not.toHaveClass('absolute', 'right-0');
expect(thinking).not.toHaveTextContent('先确认 project.json 项目边界,再读取配置。');
expect(thinking).toHaveAttribute('data-collapsed-lines', '1');
expect(thinking).toHaveAttribute('data-expanded', 'false');
expect(thinking).toHaveAttribute('data-streaming', 'true');
expect(thinking).toHaveClass('max-h-[1.65em]', 'overflow-hidden');
expect(thinking).toBeVisible();
const thinkingToggle = screen.getByRole('button', { name: '展开思考详情' });
expect(thinkingToggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(thinkingToggle);
expect(thinking).toHaveAttribute('data-expanded', 'true');
expect(thinking).toHaveClass('max-h-60', 'overflow-y-auto');
expect(thinking).toHaveTextContent('先确认 project.json 项目边界,再读取配置。');
expect(within(thinking).getByText('project.json').tagName).toBe('CODE');
expect(within(thinking).getByTestId('assistant-markdown')).toHaveClass('text-muted-foreground');
expect(screen.getByRole('button', { name: '收起思考详情' }))
.toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: '收起思考详情' }));
expect(thinking).toHaveAttribute('data-expanded', 'false');
const processNote = screen.getByLabelText('过程说明');
const assistantCommentary = processNote.closest('[data-node-kind="assistant-commentary"]')!;
expect(assistantCommentary).toHaveClass('text-foreground');
expect(processNote).toHaveAttribute('data-collapsed-lines', '1');
expect(processNote).toHaveClass('max-h-[1.65em]', 'overflow-hidden');
const commentaryPreview = within(processNote).getByTestId('process-progress-preview');
expect(commentaryPreview).toHaveTextContent('让我看看 index.html 当前的实际内容:');
expect(commentaryPreview).toHaveClass('text-foreground');
expect(commentaryPreview).not.toHaveClass('text-muted-foreground/75');
expect(within(commentaryPreview).getByText('index.html')).toHaveClass('text-foreground');
expect(within(commentaryPreview).getByText('index.html')).not.toHaveClass('text-muted-foreground');
expect(commentaryPreview).toHaveAttribute('data-progress-shimmer', 'false');
expect(commentaryPreview).toHaveAttribute('data-progress-update-motion', 'none');
expect(commentaryPreview).not.toHaveAttribute('data-roll-revision');
expect(commentaryPreview.querySelector('.streaming-progress-shimmer')).not.toBeInTheDocument();
expect(commentaryPreview.querySelector('.streaming-progress-roll')).not.toBeInTheDocument();
expect(processNote).not.toHaveTextContent('好的,两件事都办');
fireEvent.click(screen.getByRole('button', { name: '展开过程说明' }));
expect(screen.getByText(/好的,两件事都办/).closest('[data-testid="coding-process-group"]')).toBe(process);
expect(within(processNote).getByTestId('assistant-markdown')).toHaveClass('text-foreground');
expect(within(processNote).getByTestId('assistant-markdown')).not.toHaveClass('text-muted-foreground');
fireEvent.click(screen.getByRole('button', { name: '收起过程说明' }));
const tool = screen.getByText('读取项目配置').closest('details')!;
expect(tool).not.toHaveAttribute('open');
expect(tool.querySelector('summary')).toHaveAttribute('data-collapsed-lines', '1');
expect(tool.querySelector('summary')).toHaveClass('h-8', 'overflow-hidden');
const toolPreview = within(tool).getByTestId('tool-progress-preview');
const initialToolRevision = toolPreview.getAttribute('data-roll-revision');
expect(toolPreview).toHaveTextContent('正在读取入口文件…');
expect(toolPreview).toHaveAttribute('data-progress-tail', 'true');
expect(toolPreview).toHaveAttribute('data-progress-alignment', 'left');
expect(toolPreview).toHaveAttribute('data-progress-update-motion', 'roll');
expect(toolPreview).toHaveAttribute('data-progress-shimmer', 'true');
expect(toolPreview).toHaveClass('text-muted-foreground/75');
expect(screen.getByText('读取项目配置')).toHaveClass('text-muted-foreground');
expect(toolPreview).toHaveClass('text-left');
expect(within(tool).getByText('执行中')).toBeVisible();
const updatedSnapshot = createStreamingSnapshot(
snapshot.cursor.seq + 1,
`${initialThinkingText}\n\n正在确认最新入口 \`main.ts\``,
`${initialNoteText}\n\n正在整理最新结论。`,
`${initialToolOutput}\n正在追踪最新工具输出main.ts 已读取。`,
);
act(() => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-streaming-process',
workerGeneration: 1,
seq: updatedSnapshot.cursor.seq,
snapshot: updatedSnapshot,
}));
expect(within(thinking).getByTestId('process-progress-preview'))
.toHaveTextContent('正在确认最新入口 main.ts。');
expect(within(thinking).getByTestId('process-progress-preview'))
.not.toHaveAttribute('data-roll-revision');
expect(within(thinking).getByTestId('process-progress-preview')
.querySelector('.streaming-progress-roll')).not.toBeInTheDocument();
expect(within(processNote).getByTestId('process-progress-preview'))
.toHaveTextContent('正在整理最新结论。');
expect(within(processNote).getByTestId('process-progress-preview'))
.toHaveClass('text-foreground');
expect(within(processNote).getByTestId('process-progress-preview'))
.toHaveAttribute('data-progress-shimmer', 'false');
expect(within(tool).getByTestId('tool-progress-preview'))
.toHaveTextContent('正在追踪最新工具输出main.ts 已读取。');
expect(within(tool).getByTestId('tool-progress-preview'))
.not.toHaveAttribute('data-roll-revision', initialToolRevision);
fireEvent.click(tool.querySelector('summary')!);
await waitFor(() => expect(tool).toHaveAttribute('open'));
const toolDetails = within(tool).getByTestId('tool-details');
expect(within(toolDetails).getByText('当前状态:执行中')).toBeVisible();
expect(within(toolDetails).getByText(/已定位项目根目录/)).toBeVisible();
});
it('folds completed process data into elapsed time, hides internal boundaries, and keeps the conclusion visible', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-completed-process', 1);
const snapshot = {
...base,
run: {
status: 'idle' as const,
runId: 'run-completed-process',
startedAt: 1_000,
settledAt: 5_500,
terminalReason: 'completed' as const,
},
nodes: [
{
kind: 'message' as const,
id: 'message-user-completed-process',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'user-text-completed-process', text: '检查项目', status: 'complete' as const }],
},
{
kind: 'boundary' as const,
id: 'boundary-start-completed-process',
runId: 'run-completed-process',
boundary: 'turn-start' as const,
},
{
kind: 'message' as const,
id: 'message-work-completed-process',
role: 'assistant' as const,
status: 'complete' as const,
stopReason: 'tool-use' as const,
blocks: [
{ kind: 'thinking' as const, id: 'thinking-completed-process', text: '先读取配置,再核对入口。', status: 'streaming' as const },
{ kind: 'text' as const, id: 'preamble-completed-process', text: '我先检查项目配置。', status: 'complete' as const },
],
},
{
kind: 'tool' as const,
id: 'tool-completed-process',
toolCallId: 'call-completed-process',
toolName: 'read_file',
title: '读取项目配置',
inputText: 'package.json',
status: 'complete' as const,
output: [{ kind: 'text' as const, id: 'tool-output-completed-process', text: '工具输出内容', status: 'complete' as const }],
},
{
kind: 'boundary' as const,
id: 'boundary-end-completed-process',
runId: 'run-completed-process',
boundary: 'turn-end' as const,
},
{
kind: 'message' as const,
id: 'message-final-completed-process',
role: 'assistant' as const,
status: 'complete' as const,
stopReason: 'stop' as const,
blocks: [{ kind: 'text' as const, id: 'final-text-completed-process', text: '结论:项目入口与配置一致。', status: 'complete' as const }],
},
],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-completed-process',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(<CodingConversationTimeline conversationId="conversation-completed-process" />);
const process = screen.getByTestId('coding-process-group');
expect(process).not.toHaveAttribute('open');
expect(within(process).getByText('已处理 5 秒')).toBeVisible();
expect(screen.getByText('结论:项目入口与配置一致。')).toBeVisible();
expect(screen.queryByText('开始新一轮')).not.toBeInTheDocument();
expect(screen.queryByText('本轮已结算')).not.toBeInTheDocument();
expect(screen.getByLabelText('思考过程')).not.toBeVisible();
fireEvent.click(process.querySelector('summary')!);
await waitFor(() => expect(process).toHaveAttribute('open'));
const settledThinking = screen.getByLabelText('思考过程');
expect(settledThinking).toBeVisible();
expect(within(settledThinking).getByTestId('process-progress-preview'))
.toHaveAttribute('data-progress-shimmer', 'false');
expect(within(settledThinking).getByTestId('process-progress-preview')
.querySelector('.streaming-progress-shimmer')).not.toBeInTheDocument();
const tool = screen.getByText('读取项目配置').closest('details')!;
expect(tool).not.toHaveAttribute('open');
fireEvent.click(tool.querySelector('summary')!);
const toolDetails = within(tool).getByTestId('tool-details');
await waitFor(() => expect(within(toolDetails).getByText('工具输出内容')).toBeVisible());
});
it('keeps a recovered tool error inside the process details without marking the completed turn as failed', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-recovered-tool-error', 1);
const snapshot = {
...base,
run: {
status: 'idle' as const,
runId: 'run-recovered-tool-error',
startedAt: 1_000,
settledAt: 32_000,
terminalReason: 'completed' as const,
},
nodes: [
{
kind: 'message' as const,
id: 'message-user-recovered-tool-error',
role: 'user' as const,
status: 'complete' as const,
blocks: [{
kind: 'text' as const,
id: 'user-text-recovered-tool-error',
text: '继续',
status: 'complete' as const,
}],
},
{
kind: 'tool' as const,
id: 'tool-recovered-tool-error',
toolCallId: 'call-recovered-tool-error',
toolName: 'agent_browser',
title: '检查网站',
inputText: '',
status: 'error' as const,
output: [{
kind: 'text' as const,
id: 'tool-output-recovered-tool-error',
text: 'Bridge request failed',
status: 'complete' as const,
}],
},
{
kind: 'message' as const,
id: 'message-final-recovered-tool-error',
role: 'assistant' as const,
status: 'complete' as const,
stopReason: 'stop' as const,
blocks: [{
kind: 'text' as const,
id: 'final-text-recovered-tool-error',
text: '网站完成啦!',
status: 'complete' as const,
}],
},
],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-recovered-tool-error',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(<CodingConversationTimeline conversationId="conversation-recovered-tool-error" />);
const process = screen.getByTestId('coding-process-group');
expect(within(process).getByText('已处理 31 秒')).toBeVisible();
expect(within(process).queryByText(/处理失败/)).not.toBeInTheDocument();
expect(screen.getByText('网站完成啦!')).toBeVisible();
fireEvent.click(process.querySelector('summary')!);
await waitFor(() => expect(process).toHaveAttribute('open'));
const tool = screen.getByText('检查网站').closest('details')!;
expect(tool).toHaveTextContent('失败');
fireEvent.click(tool.querySelector('summary')!);
const toolDetails = within(tool).getByTestId('tool-details');
await waitFor(() => expect(within(toolDetails).getByText('Bridge request failed')).toBeVisible());
});
it('marks the process as failed when the authoritative run outcome failed', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-failed-process', 1);
const snapshot = {
...base,
run: {
status: 'error' as const,
runId: 'run-failed-process',
startedAt: 1_000,
settledAt: 6_000,
terminalReason: 'failed' as const,
},
nodes: [{
kind: 'tool' as const,
id: 'tool-failed-process',
toolCallId: 'call-failed-process',
toolName: 'bash',
title: '执行命令',
inputText: '',
status: 'error' as const,
output: [],
}],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-failed-process',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(<CodingConversationTimeline conversationId="conversation-failed-process" />);
expect(within(screen.getByTestId('coding-process-group')).getByText('处理失败 · 5 秒'))
.toBeVisible();
});
it('shows an actionable non-retryable quota failure in the process summary', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-quota-failure', 1);
const snapshot = {
...base,
run: {
status: 'idle' as const,
runId: 'run-quota-failure',
startedAt: 1_000,
settledAt: 6_000,
terminalReason: 'failed' as const,
error: {
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED' as const,
message: '词元点数余额不足,请充值后重试。',
recoverable: false,
},
},
nodes: [{
kind: 'tool' as const,
id: 'tool-quota-failure',
toolCallId: 'call-quota-failure',
toolName: 'bash',
title: '执行命令',
inputText: '',
status: 'error' as const,
output: [],
}],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-quota-failure',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(<CodingConversationTimeline conversationId="conversation-quota-failure" />);
expect(within(screen.getByTestId('coding-process-group'))
.getByText('词元点数余额不足,请充值后重试。 · 5 秒')).toBeVisible();
});
it('shows an actionable persisted Provider failure after reopening a Conversation', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-persisted-quota-failure', 1);
const snapshot = {
...base,
run: { status: 'idle' as const },
nodes: [{
kind: 'notice' as const,
id: 'persisted-quota-notice',
code: 'CODING_PROVIDER_QUOTA_EXHAUSTED',
level: 'error' as const,
message: '词元点数余额不足,请充值后重试。',
}],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-persisted-quota-failure',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(<CodingConversationTimeline conversationId="conversation-persisted-quota-failure" />);
expect(screen.getByTestId('coding-process-group').querySelector('summary'))
.toHaveTextContent('词元点数余额不足,请充值后重试。');
});
it('windows long timelines, loads near the top, and preserves the scroll anchor', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-window', 1);
const snapshot = {
...base,
nodes: Array.from({ length: 150 }, (_, index) => ({
kind: 'notice' as const,
id: `notice-${index}`,
code: `NOTICE_${index}`,
level: 'info' as const,
message: `Notice ${index}`,
})),
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-window',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render(<CodingConversationTimeline conversationId="conversation-window" />);
expect(screen.queryByText('Notice 0')).not.toBeInTheDocument();
expect(screen.getByText('Notice 30')).toBeInTheDocument();
const timeline = screen.getByTestId('coding-conversation-timeline');
Object.defineProperty(timeline, 'scrollHeight', {
configurable: true,
get: () => screen.queryByText('Notice 0') ? 1_600 : 1_000,
});
timeline.scrollTop = 0;
fireEvent.scroll(timeline);
await waitFor(() => expect(screen.getByText('Notice 0')).toBeInTheDocument());
expect(timeline.scrollTop).toBe(600);
});
it('does not commit the selected timeline when a hidden Conversation streams', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const selected = createProductSnapshot('conversation-selected', 1);
const hidden = createProductSnapshot('conversation-hidden', 1);
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-selected',
workerGeneration: 1,
seq: selected.cursor.seq,
snapshot: selected,
});
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-hidden',
workerGeneration: 1,
seq: hidden.cursor.seq,
snapshot: hidden,
});
const commits: number[] = [];
render(
<Profiler id="selected-timeline" onRender={(_id, _phase, duration) => commits.push(duration)}>
<CodingConversationTimeline conversationId="conversation-selected" />
</Profiler>,
);
commits.length = 0;
act(() => {
codingConversationStore.getState().applyPatchBatchEvent({
type: 'patch-batch',
conversationId: 'conversation-hidden',
workerGeneration: 1,
fromSeq: 1,
toSeq: 1,
items: [{
seq: 1,
at: 1,
patch: {
op: 'message.upsert',
node: {
kind: 'message',
id: 'hidden-message',
role: 'assistant',
status: 'streaming',
blocks: [{
kind: 'text',
id: 'hidden-text',
text: 'hidden token',
status: 'streaming',
}],
},
},
}],
});
});
expect(commits).toEqual([]);
});
it('renders nested subagents, compacted retry state, inline tool output, and explicit branch wording', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-feature-ui', 1);
const snapshot = {
...base,
nodes: [
{
kind: 'message' as const,
id: 'message-user-feature',
sourceEntryId: 'entry-user-feature',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-user-feature', text: 'Build it', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-feature',
sourceEntryId: 'entry-assistant-feature',
role: 'assistant' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-feature', text: 'Ready', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-optimistic-feature',
sourceEntryId: 'entry-optimistic-feature',
clientRequestId: 'request-optimistic-feature',
role: 'user' as const,
status: 'optimistic' as const,
blocks: [{ kind: 'text' as const, id: 'text-optimistic-feature', text: 'Pending', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-without-entry-feature',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-without-entry-feature', text: 'Local only', status: 'complete' as const }],
},
{
kind: 'tool' as const,
id: 'tool-feature',
toolCallId: 'call-feature',
toolName: 'read_file',
title: '读取文件',
inputText: 'src/app.ts',
status: 'complete' as const,
output: [{ kind: 'text' as const, id: 'tool-output', text: 'inline result', status: 'complete' as const }],
details: { schema: 'changed-file.v1' as const, paths: ['src/app.ts'] },
},
{
kind: 'tool' as const,
id: 'tool-changed-file-feature',
toolCallId: 'call-changed-file-feature',
toolName: 'changed_file',
title: '记录文件更改',
inputText: '',
status: 'complete' as const,
output: [],
details: { schema: 'changed-file.v1' as const, paths: ['src/hidden-from-timeline.ts'] },
},
{
kind: 'subagent' as const,
id: 'subagent-feature',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-feature',
mode: 'parallel' as const,
tasks: [
{ taskId: 'task-a', agentId: 'reader', toolProfile: 'read-only' as const, status: 'complete' as const, summary: 'Read complete' },
{ taskId: 'task-b', agentId: 'builder', toolProfile: 'coding' as const, status: 'error' as const, errorCode: 'BUILD_FAILED' },
],
},
},
{
kind: 'subagent' as const,
id: 'subagent-single',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-single',
mode: 'single' as const,
tasks: [{ taskId: 'task-single', agentId: 'reviewer', toolProfile: 'read-only' as const, status: 'aborted' as const }],
},
},
{
kind: 'subagent' as const,
id: 'subagent-chain',
runId: 'run-feature',
details: {
schema: 'subagent.v1' as const,
dispatchId: 'dispatch-chain',
mode: 'chain' as const,
tasks: [
{ taskId: 'task-chain-a', agentId: 'planner', toolProfile: 'read-only' as const, status: 'complete' as const },
{ taskId: 'task-chain-b', agentId: 'implementer', toolProfile: 'coding' as const, status: 'skipped' as const },
],
},
},
{
kind: 'compaction' as const,
id: 'compact-feature',
runId: 'run-feature',
source: 'automatic' as const,
status: 'error' as const,
willRetry: true,
summary: '保留了本轮关键上下文。',
},
],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-feature-ui',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
const onFork = vi.fn();
render(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />);
const forkActions = screen.getAllByRole('button', { name: '从这里创建新对话分支' });
expect(forkActions).toHaveLength(1);
fireEvent.click(forkActions[0]!);
expect(onFork).toHaveBeenCalledWith('entry-user-feature');
const process = screen.getByTestId('coding-process-group');
expect(process).not.toHaveAttribute('open');
fireEvent.click(process.querySelector('summary')!);
const parallel = document.querySelector('[data-node-id="subagent-feature"]')!;
const single = document.querySelector('[data-node-id="subagent-single"]')!;
const chain = document.querySelector('[data-node-id="subagent-chain"]')!;
expect(parallel.querySelector('summary')).toHaveTextContent('并行子任务');
expect(single.querySelector('summary')).toHaveTextContent('单个子任务');
expect(chain.querySelector('summary')).toHaveTextContent('串行子任务');
fireEvent.click(parallel.querySelector('summary')!);
fireEvent.click(single.querySelector('summary')!);
fireEvent.click(chain.querySelector('summary')!);
expect(screen.getByText('reader')).toBeVisible();
expect(screen.getByText('BUILD_FAILED')).toBeVisible();
const compaction = document.querySelector('[data-node-id="compact-feature"]')!;
fireEvent.click(compaction.querySelector('summary')!);
expect(screen.getByText('系统会自动重试。')).toBeVisible();
const tool = document.querySelector('[data-node-id="tool-feature"]')!;
expect(tool).not.toHaveAttribute('open');
fireEvent.click(tool.querySelector('summary')!);
const toolDetails = within(tool as HTMLElement).getByTestId('tool-details');
expect(within(toolDetails).getByText('inline result')).toBeVisible();
expect(within(toolDetails).getByText('inline result').closest('[data-node-kind="tool"]')).not.toBeNull();
expect(document.querySelector('[data-node-id="tool-changed-file-feature"]')).toBeNull();
expect(screen.queryByText(/回滚|revert/i)).not.toBeInTheDocument();
});
});