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(); }); });