import type { ChatTimelineMessageItem, ChatTimelineStatusItem, } from '@easyflow/common-ui'; import { describe, expect, it } from 'vitest'; import { recordsToTimelineItems } from './agentTimelineAdapter'; describe('agentTimelineAdapter', () => { it('restores successful turn duration from persisted message timestamps', () => { const items = recordsToTimelineItems([ { created: '2026-08-15T10:00:00Z', id: 'timer-user', roundId: 'round-timer', senderRole: 'user', contentText: '开始计时', }, { created: '2026-08-15T10:00:18Z', id: 'timer-assistant', roundId: 'round-timer', senderRole: 'assistant', contentText: '计时完成', contentPayload: { agentResult: { text: '计时完成' }, terminalStatus: 'COMPLETED', }, }, ]); const turnItems = items.filter((item) => item.roundId === 'round-timer'); expect(turnItems).not.toHaveLength(0); expect( turnItems.every( (item) => item.turnStartedAt === Date.parse('2026-08-15T10:00:00Z') && item.turnFinishedAt === Date.parse('2026-08-15T10:00:18Z'), ), ).toBe(true); }); it('restores available and expired Artifacts with the shared safe projection', () => { const items = recordsToTimelineItems([ { id: 'artifact-history', senderRole: 'assistant', contentText: '报告已生成', roundId: 'round-artifact-history', contentPayload: { agentResult: { text: '报告已生成' }, artifacts: [ { artifactId: '01JAVAILABLE', bucket: 'easyflow-agent-artifacts', fileName: '报告.pdf', objectKey: 'formal/private', sha256: 'b'.repeat(64), size: 4096, status: 'AVAILABLE', }, { artifactId: '01JEXPIRED', fileName: '旧报告.xlsx', status: 'EXPIRED', }, ], }, }, ]); const artifacts = items.filter((item) => item.type === 'artifact'); expect(artifacts).toHaveLength(2); expect(artifacts).toEqual([ expect.objectContaining({ downloadUrl: '/api/v1/agent/artifacts/01JAVAILABLE/content', status: 'available', }), expect.objectContaining({ downloadUrl: undefined, status: 'expired', }), ]); expect(JSON.stringify(artifacts)).not.toMatch( /easyflow-agent-artifacts|objectKey|formal\/private/, ); }); it('uses refreshed direct Artifact status instead of stale runtime events', () => { const items = recordsToTimelineItems([ { id: 'artifact-status-conflict', senderRole: 'assistant', contentText: '产物已过期', roundId: 'round-artifact-status-conflict', contentPayload: { artifacts: [ { artifactId: '01JSTATUSCONFLICT', fileName: '账本报告.pdf', status: 'EXPIRED', }, ], runtimeEvents: [ { name: 'easyflow.artifact.published', value: { artifactId: '01JSTATUSCONFLICT', fileName: '旧事件报告.pdf', status: 'AVAILABLE', }, }, ], }, }, ]); const artifacts = items.filter((item) => item.type === 'artifact'); expect(artifacts).toEqual([ expect.objectContaining({ artifactId: '01JSTATUSCONFLICT', downloadUrl: undefined, fileName: '账本报告.pdf', status: 'expired', }), ]); }); it('projects history records to chat timeline items', () => { const items = recordsToTimelineItems([ { id: '1', senderRole: 'user', contentText: '帮我查一下', roundId: 'r1', contentPayload: { attachments: [ { attachmentRef: 'formal:r1:document:0', downloadUrl: '/api/v1/agent/media/document/content?reference=formal:r1:document:0', name: '需求说明.docx', readSnapshotId: 'snapshot-1', size: 1024, }, ], }, }, { id: '2', senderRole: 'assistant', contentText: '查到了', roundId: 'r1', contentPayload: { agentResult: { reasoning: '先检索', text: '查到了', knowledgeReferences: [ { documentName: '手册', chunkContent: '内容片段', }, ], }, chains: [ { id: 'tool-1', name: 'search', status: 'TOOL_RESULT', arguments: { q: 'EasyFlow' }, result: 'ok', }, ], }, }, ]); expect( items.some((item) => item.type === 'message' && item.role === 'user'), ).toBe(true); const user = items.find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'user', ); expect(user?.documents?.[0]).toEqual( expect.objectContaining({ attachmentRef: 'formal:r1:document:0', name: '需求说明.docx', status: 'ready', }), ); const assistant = items.find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant', ); expect(assistant?.parts.some((part) => part.type === 'thinking')).toBe( true, ); expect(assistant?.parts.some((part) => part.type === 'text')).toBe(true); expect(items.some((item) => item.type === 'tool')).toBe(true); expect(assistant?.knowledgeItems?.[0]?.documentName).toBe('手册'); expect( items .filter((item) => item.type !== 'message' || item.role !== 'user') .every((item) => item.roundId === 'r1' && item.turnSucceeded === true), ).toBe(true); }); it('keeps a cancelled partial assistant turn expanded after history restore', () => { const items = recordsToTimelineItems([ { id: 'cancelled-assistant', senderRole: 'assistant', contentText: '取消前的部分输出', created: '2026-08-15T10:00:00Z', roundId: 'cancelled-round', contentPayload: { agentResult: { text: '取消前的部分输出' }, terminalStatus: 'CANCELLED', }, }, ]); expect( items .filter((item) => item.roundId === 'cancelled-round') .every( (item) => item.turnSucceeded === false && item.turnFinishedAt !== undefined, ), ).toBe(true); }); it('keeps stable ids when history has reasoning, tools and final text', () => { const items = recordsToTimelineItems([ { id: '42', senderRole: 'assistant', contentText: '最终回答', roundId: 'r2', contentPayload: { agentResult: { text: '最终回答', }, chains: [ { reasoning_content: '先思考', }, { id: 'tool-2', name: 'search', status: 'TOOL_RESULT', result: 'ok', }, ], messageChain: [ { role: 'assistant', reasoningContent: '中间思考', toolCalls: [{ id: 'tool-2', name: 'search', arguments: '{}' }], }, { role: 'tool', toolCallId: 'tool-2', content: 'ok', }, ], }, }, ]); const ids = items.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); expect( items.filter( (item) => item.type === 'message' && item.role === 'assistant', ), ).toHaveLength(2); expect( items.some((item) => item.type === 'tool' && item.status === 'success'), ).toBe(true); }); it('uses tool display name when restoring aliased MCP tools from history', () => { const items = recordsToTimelineItems([ { id: 'mcp-history', senderRole: 'assistant', contentText: '已完成', roundId: 'round-mcp', contentPayload: { chains: [ { id: 'tool-mcp-1', name: 'mcp_123_search', toolDisplayName: 'Context MCP - search', status: 'TOOL_RESULT', result: 'ok', }, ], messageChain: [ { role: 'assistant', toolCalls: [ { id: 'tool-mcp-1', name: 'mcp_123_search', toolDisplayName: 'Context MCP - search', arguments: '{}', }, ], }, { role: 'tool', toolCallId: 'tool-mcp-1', content: 'ok', }, ], }, }, ]); const tool = items.find((item) => item.type === 'tool'); expect(tool?.toolName).toBe('Context MCP - search'); }); it('hides knowledge retrieval cards when restoring agent chat history', () => { const items = recordsToTimelineItems([ { id: '417197643647811584', senderRole: 'assistant', contentText: '根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。', roundId: '417197622424633344', contentPayload: { chains: [ { id: 'call_0fc660e9d203416983ccca7e', name: 'retrieve_knowledge', result: 'Retrieved 2 relevant document(s)', status: 'TOOL_RESULT', arguments: { query: '暑假时间', }, }, ], agentResult: { text: '根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。', knowledgeReferences: [ { chunkContent: '问题:2026 年暑假安排\n答案:2026 年7 月 1 日到 8 月 15 日', documentId: '411358369563336704', knowledgeName: 'faq', knowledgeType: 'FAQ', }, ], }, messageChain: [ { role: 'assistant', toolCalls: [ { id: 'call_0fc660e9d203416983ccca7e', name: 'retrieve_knowledge', arguments: '{query=暑假时间}', }, ], }, { role: 'tool', content: 'Retrieved 2 relevant document(s)', toolCallId: 'call_0fc660e9d203416983ccca7e', }, { role: 'assistant', content: '根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。', }, ], }, }, ]); expect(items.some((item) => item.type === 'tool')).toBe(false); expect( items.some( (item) => item.type === 'status' && item.label === '已检索知识库', ), ).toBe(true); expect( items.some( (item) => item.type === 'message' && item.role === 'assistant', ), ).toBe(true); const assistant = items.find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant', ); expect(assistant?.knowledgeItems?.[0]?.knowledgeName).toBe('faq'); }); it('hides internal fragment and context reload tools from history', () => { const items = recordsToTimelineItems([ { id: 'internal-tools', senderRole: 'assistant', contentText: '已处理', roundId: 'round-internal', contentPayload: { chains: [ { id: 'fragment-1', name: '__fragment__', status: 'TOOL_RESULT', result: 'fragment', }, { id: 'context-1', name: 'context_reload', status: 'TOOL_RESULT', result: 'reload', }, ], agentResult: { text: '已处理', }, messageChain: [ { role: 'assistant', toolCalls: [ { id: 'fragment-1', name: '__fragment__' }, { id: 'context-1', name: 'context_reload' }, ], }, { role: 'tool', toolCallId: 'fragment-1', content: 'fragment', }, { role: 'tool', toolCallId: 'context-1', content: 'reload', }, ], }, }, ]); expect(items.some((item) => item.type === 'tool')).toBe(false); expect(items.some((item) => item.type === 'status')).toBe(false); expect( items.some( (item) => item.type === 'message' && item.role === 'assistant', ), ).toBe(true); }); it('restores only terminal Skill invocation states from safe history fields', () => { const items = recordsToTimelineItems([ { id: 'skill-history', senderRole: 'assistant', contentText: '处理结束', roundId: 'round-skill-history', contentPayload: { agentResult: { text: '处理结束' }, skillInvocationStatuses: [ { input: { private: true }, path: 'references/private.md', skillContent: 'private body', skillDisplayName: '不会恢复的运行态', status: 'RUNNING', statusKey: 'skill-invocation:round-skill-history:running', }, { configJson: { token: 'secret' }, skillDisplayName: '合同审查助手', status: 'SUCCESS', statusKey: 'skill-invocation:round-skill-history:101', }, { skillDisplayName: '数据分析助手', status: 'FAILED', statusKey: 'skill-invocation:round-skill-history:102', }, { skillDisplayName: '流程检查助手', status: 'CANCELLED', statusKey: 'skill-invocation:round-skill-history:103', }, { skillDisplayName: '规范化助手', status: 'INCOMPLETE', statusKey: 'skill-invocation:round-skill-history:104', }, ], }, }, ]); const skillStatuses = items.filter( (item): item is ChatTimelineStatusItem => item.type === 'status' && item.icon === 'skill', ); expect(skillStatuses).toHaveLength(4); expect(skillStatuses.map((item) => item.status)).toEqual([ 'done', 'error', 'cancelled', 'incomplete', ]); expect(JSON.stringify(skillStatuses)).not.toMatch( /不会恢复的运行态|skillContent|private body|private\.md|configJson|token/, ); }); });