import { describe, it, expect } from 'vitest' import { toolKind, toolHeadline, toolGroupLabel, timelineEntries } from '../src/toolkind' import type { Part } from '../src/contracts' function p(partial: Partial & { type: Part['type'] }): Part { return partial as Part } describe('toolKind', () => { it('classifies known tools and falls back to other', () => { expect(toolKind('fs_read_file')).toBe('read') expect(toolKind('web_search')).toBe('search') expect(toolKind('run_python')).toBe('exec') expect(toolKind('register_artifact')).toBe('write') expect(toolKind('submit_card')).toBe('card') expect(toolKind('delegate_task')).toBe('delegate') expect(toolKind('update_todos')).toBe('plan') expect(toolKind('time_now')).toBe('other') }) it('pulls a path, a query, or the first script line out of arguments', () => { expect(toolHeadline('fs_read_file', '{"path":"notes/a.md"}')).toBe('notes/a.md') expect(toolHeadline('web_search', '{"query":"会议冲突"}')).toBe('会议冲突') expect(toolHeadline('run_python', '{"script":"print(1)\\nprint(2)"}')).toBe('print(1)') expect(toolGroupLabel('read', 2, false)).toBe('查阅 · 2 个文件') expect(toolGroupLabel('exec', 1, true)).toBe('运行命令 · 1 条') }) }) describe('timelineEntries', () => { it('merges consecutive tools of the same kind and leaves narration in place', () => { const parts: Part[] = [ p({ type: 'thinking', process: true, text: '先看文件', durationMs: 1500 }), p({ type: 'tool_call', process: true, toolCall: { id: 'a', name: 'fs_read_file', arguments: '{"path":"a.md"}' } }), p({ type: 'tool_result', process: true, toolResult: { callId: 'a', status: 'ok', summary: '12 行' } }), p({ type: 'tool_call', process: true, toolCall: { id: 'b', name: 'fs_read_file', arguments: '{"path":"b.md"}' } }), p({ type: 'tool_result', process: true, toolResult: { callId: 'b', status: 'ok' } }), p({ type: 'text', process: true, text: '两份都读完了' }), p({ type: 'tool_call', process: true, toolCall: { id: 'c', name: 'run_python', arguments: '{"script":"print(1)"}' } }), ] const rows = timelineEntries(parts, true) expect(rows.map(r => r.kind)).toEqual(['thinking', 'tools', 'text', 'toolblock']) const reads = rows[1] if (reads.kind !== 'tools') throw new Error('expected tool group') expect(reads.toolKind).toBe('read') expect(reads.items.map(it => it.headline)).toEqual(['a.md', 'b.md']) expect(reads.items[0].summary).toBe('12 行') const thinking = rows[0] if (thinking.kind !== 'thinking') throw new Error('expected thinking') expect(thinking.active).toBe(false) const exec = rows[3] if (exec.kind !== 'toolblock') throw new Error('expected exec block') expect(exec.item.running).toBe(true) expect(exec.item.headline).toBe('print(1)') }) it('renders a check row instead of narration', () => { const rows = timelineEntries([ p({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }), p({ type: 'check', process: true, check: { passed: true } }), ], false) expect(rows).toEqual([ { kind: 'check', passed: false, reason: '纸张不是 A4' }, { kind: 'check', passed: true, reason: '' }, ]) }) it('marks the trailing thinking row active while the run is live', () => { const rows = timelineEntries([p({ type: 'thinking', process: true, text: '还在想' })], true) expect(rows[0]).toMatchObject({ kind: 'thinking', active: true }) expect(timelineEntries([p({ type: 'thinking', process: true, text: '想完了' })], false)[0]) .toMatchObject({ kind: 'thinking', active: false }) }) })