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('shows one Game Resource progress card from generation through automatic project save', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-game-resource-progress', 1);
const details = (phase: 'saving' | 'saved') => ({
schema: 'makelore-capability.v1' as const,
plugin_id: 'makelore.game-resource',
plugin_version: '1.0.0',
capability_id: 'game-resource.generate',
operation: 'generate',
request_id: 'pi:game-run:game-tool',
success: true,
status: phase === 'saved' ? 200 : 202,
code: null,
error: null,
retryable: false,
billing: phase === 'saved' ? {
mode: 'platform_metered' as const,
status: 'settled' as const,
reserved_points: '2.00',
actual_points: '2.00',
usage_amount: 1,
unit: 'generation',
} : {
mode: 'platform_metered' as const,
status: 'dispatched' as const,
reserved_points: '2.00',
usage_amount: 1,
unit: 'generation',
},
payload_schema: 'game-resource.v1',
data: {
phase,
executionId: '11111111-1111-4111-8111-111111111111',
providerStatus: 'succeeded',
deliveryStatus: phase,
outputCount: 2,
files: phase === 'saved' ? [
{ path: 'assets/generated/game-resource/execution/output-1.png', bytes: 128 },
{ path: 'assets/generated/game-resource/execution/output-2.png', bytes: 256 },
] : [],
},
});
const snapshot = (seq: number, phase: 'saving' | 'saved') => {
const toolDetails = details(phase);
return {
...base,
cursor: { ...base.cursor, seq },
nodes: [{
kind: 'tool' as const,
id: 'tool-game-resource-progress',
toolCallId: 'game-tool',
toolName: 'game_resource_generate',
title: '生成游戏资源',
inputText: '',
status: phase === 'saved' ? 'complete' as const : 'running' as const,
output: [{
kind: 'text' as const,
id: `tool-output-game-resource-${phase}`,
text: JSON.stringify(toolDetails),
status: phase === 'saved' ? 'complete' as const : 'streaming' as const,
}],
details: toolDetails,
}],
};
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-game-resource-progress',
workerGeneration: 1,
seq: base.cursor.seq,
snapshot: snapshot(base.cursor.seq, 'saving'),
});
render();
expect(screen.getByTestId('tool-progress-preview')).toHaveTextContent(
'游戏资源 · 正在保存到项目',
);
const nextSeq = base.cursor.seq + 1;
act(() => codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-game-resource-progress',
workerGeneration: 1,
seq: nextSeq,
snapshot: snapshot(nextSeq, 'saved'),
}));
await waitFor(() => expect(screen.getByTestId('tool-progress-preview'))
.toHaveTextContent('游戏资源 · 已保存 2 个文件'));
});
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(
,
);
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();
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(
,
);
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('先确认 project.json 项目边界,再读取配置。');
expect(thinkingPreview).toHaveAttribute('data-progress-origin', 'head');
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('最后确认本轮只读取、不修改项目内容。');
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('好的,两件事都办:先再试一次产品素材服务,然后整体优化手机移动端体验。');
expect(commentaryPreview).toHaveAttribute('data-progress-origin', 'head');
expect(commentaryPreview).toHaveClass('text-foreground');
expect(commentaryPreview).not.toHaveClass('text-muted-foreground/75');
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('让我看看 index.html 当前的实际内容:');
fireEvent.click(screen.getByRole('button', { name: '展开过程说明' }));
expect(screen.getByText(/好的,两件事都办/).closest('[data-testid="coding-process-group"]')).toBe(process);
expect(within(processNote).getByText('index.html').tagName).toBe('CODE');
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-origin', 'head');
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('先确认 project.json 项目边界,再读取配置。');
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('已定位项目根目录');
expect(within(tool).getByTestId('tool-progress-preview').getAttribute('data-roll-revision'))
.toBe(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: '我先检查项目配置。\n\n现在开始核对 `index.html` 入口文件。',
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();
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 completedCommentary = document.querySelector(
'[data-node-id="message-work-completed-process"][data-node-kind="assistant-commentary"]',
)!;
const completedCommentaryViewport = within(completedCommentary as HTMLElement)
.getByLabelText('过程说明');
expect(completedCommentaryViewport).toHaveAttribute('data-streaming', 'false');
expect(completedCommentaryViewport).toHaveAttribute('data-expanded', 'false');
expect(within(completedCommentaryViewport).getByTestId('process-progress-preview'))
.toHaveTextContent('我先检查项目配置。');
expect(within(completedCommentaryViewport).getByTestId('process-progress-preview'))
.toHaveAttribute('data-progress-origin', 'head');
expect(completedCommentaryViewport).not.toHaveTextContent('现在开始核对 index.html 入口文件。');
const completedCommentaryToggle = within(completedCommentary as HTMLElement)
.getByRole('button', { name: '展开过程说明' });
expect(completedCommentaryToggle).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(completedCommentaryToggle);
expect(completedCommentaryViewport).toHaveAttribute('data-expanded', 'true');
expect(completedCommentaryViewport).toHaveTextContent('我先检查项目配置。');
expect(completedCommentaryViewport).toHaveTextContent('现在开始核对 index.html 入口文件。');
expect(within(completedCommentaryViewport).getByText('index.html').tagName).toBe('CODE');
fireEvent.click(within(completedCommentary as HTMLElement)
.getByRole('button', { name: '收起过程说明' }));
expect(completedCommentaryViewport).toHaveAttribute('data-expanded', 'false');
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();
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();
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();
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();
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();
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(
commits.push(duration)}>
,
);
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();
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();
});
it('renders receipt-unavailable as a generic hosted-plugin billing state', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import(
'@/pages/Chat/CodingConversationTimeline'
);
const base = createProductSnapshot('conversation-web-search-billing', 1);
const snapshot = {
...base,
nodes: [{
kind: 'tool' as const,
id: 'tool-web-search-billing',
toolCallId: 'call-web-search-billing',
toolName: 'game_resource_generate',
title: '生成游戏资源',
inputText: '{"kind":"sprite"}',
status: 'error' as const,
output: [],
details: {
schema: 'makelore-capability.v1' as const,
plugin_id: 'makelore.game-resource',
plugin_version: '1.0.0',
capability_id: 'game-resource.generate',
operation: 'generate',
request_id: 'pi:run-a:resource-a',
success: false,
status: 503,
code: 'plugin_receipt_unavailable',
error: 'Billing status could not be synchronized',
retryable: false,
billing: { mode: 'platform_metered' as const, status: 'receipt_unavailable' as const },
payload_schema: 'game-resource.v1',
data: null,
},
}],
};
codingConversationStore.getState().applySnapshotEvent({
type: 'snapshot',
conversationId: 'conversation-web-search-billing',
workerGeneration: 1,
seq: snapshot.cursor.seq,
snapshot,
});
render();
const process = screen.getByTestId('coding-process-group');
fireEvent.click(process.querySelector('summary')!);
const tool = document.querySelector('[data-node-id="tool-web-search-billing"]')!;
fireEvent.click(tool.querySelector('summary')!);
const details = within(tool as HTMLElement).getByTestId('tool-details');
expect(within(details).getByText('收费状态未同步,请勿重复发起')).toBeVisible();
expect(within(details).queryByText(/来源|搜索结果|Makelore\.com/u)).not.toBeInTheDocument();
});
});