Why: Deliverable cards appeared as soon as a file was registered, and the last draft was shown as the answer before the run had finished composing one. Evidence: vitest run test/live.test.ts test/toolkind.test.ts test/messageparts.test.ts test/design-guard.test.ts Impact: Cards wait until the run ends. A finalizing phase keeps earlier text in the process block, and check rows render there. Refs: feature/final-reply-compose Co-authored-by: Cursor <cursoragent@cursor.com>
75 lines
3.7 KiB
TypeScript
75 lines
3.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { toolKind, toolHeadline, toolGroupLabel, timelineEntries } from '../src/toolkind'
|
|
import type { Part } from '../src/contracts'
|
|
|
|
function p(partial: Partial<Part> & { 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 })
|
|
})
|
|
})
|