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>
190 lines
6.9 KiB
TypeScript
190 lines
6.9 KiB
TypeScript
// 工具调用按类别合并成时间线的一行。新增类别必须补进 ToolKind 与两处 switch,
|
||
// 否则 never 检查会在编译期失败。
|
||
import type { Part } from './contracts'
|
||
|
||
export type ToolKind = 'read' | 'search' | 'exec' | 'write' | 'card' | 'delegate' | 'plan' | 'other'
|
||
|
||
const READ = new Set(['fs_read_file', 'fs_list_dir', 'read_conversation', 'list_conversations'])
|
||
const SEARCH = new Set(['knowledge_search', 'tavily_search', 'web_search', 'history_search'])
|
||
const EXEC = new Set(['run_shell', 'run_python'])
|
||
const WRITE = new Set(['write_artifact', 'register_artifact', 'fs_write_file'])
|
||
const CARD = new Set(['submit_card', 'card_begin', 'card_append', 'card_commit'])
|
||
const DELEGATE = new Set(['delegate_task', 'squad_orchestrate'])
|
||
const PLAN = new Set(['update_plan', 'update_todos'])
|
||
|
||
export function toolKind(name: string): ToolKind {
|
||
if (READ.has(name)) return 'read'
|
||
if (SEARCH.has(name)) return 'search'
|
||
if (EXEC.has(name)) return 'exec'
|
||
if (WRITE.has(name)) return 'write'
|
||
if (CARD.has(name)) return 'card'
|
||
if (DELEGATE.has(name)) return 'delegate'
|
||
if (PLAN.has(name)) return 'plan'
|
||
return 'other'
|
||
}
|
||
|
||
function argOf(raw: string | undefined, keys: string[]): string {
|
||
if (!raw) return ''
|
||
try {
|
||
const v = JSON.parse(raw) as Record<string, unknown>
|
||
for (const k of keys) {
|
||
const x = v[k]
|
||
if (typeof x === 'string' && x.trim()) return x.trim()
|
||
}
|
||
} catch {
|
||
return ''
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/** 编写时看脚本尾部,运行后有输出则改看输出尾部。 */
|
||
export function toolTail(args?: string, output?: string): string {
|
||
const script = argOf(args, ['script', 'command', 'cmd'])
|
||
const body = (output && output.trim()) ? output : (script || args || '')
|
||
const lines = body.split('\n').filter((line, i, all) => line.trim() || i === all.length - 1)
|
||
return lines.slice(-6).join('\n')
|
||
}
|
||
|
||
function firstLine(s: string): string {
|
||
const line = s.split('\n').map(x => x.trim()).find(Boolean) ?? ''
|
||
return line.length > 80 ? `${line.slice(0, 80)}…` : line
|
||
}
|
||
|
||
/** 从工具参数里取出给用户看的一行:路径、查询词或脚本首行。 */
|
||
export function toolHeadline(name: string, args?: string): string {
|
||
const kind = toolKind(name)
|
||
switch (kind) {
|
||
case 'read': return argOf(args, ['path', 'file', 'conversationId']) || name
|
||
case 'search': return argOf(args, ['query', 'q']) || name
|
||
case 'exec': return firstLine(argOf(args, ['script', 'command', 'cmd'])) || name
|
||
case 'write': return argOf(args, ['path', 'name', 'filename']) || name
|
||
case 'card': return argOf(args, ['title']) || name
|
||
case 'delegate': return argOf(args, ['task', 'goal', 'name']) || name
|
||
case 'plan': return argOf(args, ['title']) || name
|
||
case 'other': return name
|
||
default: {
|
||
const exhaustive: never = kind
|
||
return exhaustive
|
||
}
|
||
}
|
||
}
|
||
|
||
export function toolGroupLabel(kind: ToolKind, count: number, running: boolean): string {
|
||
switch (kind) {
|
||
case 'read': return `查阅 · ${count} 个文件`
|
||
case 'search': return `搜索 · ${count} 次`
|
||
case 'exec': return running ? `运行命令 · ${count} 条` : `已运行命令 · ${count} 条`
|
||
case 'write': return `写入文件 · ${count} 个`
|
||
case 'card': return `卡片 · ${count} 张`
|
||
case 'delegate': return `委派 · ${count} 个任务`
|
||
case 'plan': return `更新计划 · ${count} 次`
|
||
case 'other': return `工具 · ${count} 次`
|
||
default: {
|
||
const exhaustive: never = kind
|
||
return exhaustive
|
||
}
|
||
}
|
||
}
|
||
|
||
export interface ToolGroupItem {
|
||
id: string
|
||
name: string
|
||
headline: string
|
||
arguments?: string
|
||
output?: string
|
||
phase?: 'composing' | 'running' | 'done'
|
||
durationMs?: number
|
||
status?: 'ok' | 'error'
|
||
summary?: string
|
||
running: boolean
|
||
}
|
||
|
||
export type TimelineEntry =
|
||
| { kind: 'thinking'; text: string; durationMs?: number; active: boolean }
|
||
| { kind: 'text'; text: string }
|
||
| { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] }
|
||
| { kind: 'toolblock'; toolKind: ToolKind; item: ToolGroupItem }
|
||
| { kind: 'check'; passed: boolean; reason: string }
|
||
| { kind: 'other'; part: Part }
|
||
|
||
const SOLO: ReadonlySet<ToolKind> = new Set(['exec', 'write', 'delegate'])
|
||
|
||
interface ToolFace {
|
||
toolPhase?: 'composing' | 'running' | 'done'
|
||
toolOutput?: string
|
||
}
|
||
|
||
function toolItem(call: Part, res: Part | undefined, live: boolean): ToolGroupItem {
|
||
const face = call as Part & ToolFace
|
||
const status = res?.toolResult?.status
|
||
const phase = face.toolPhase ?? (live && !res ? 'running' : res ? 'done' : undefined)
|
||
return {
|
||
id: call.toolCall?.id || call.toolCall?.name || 'tool',
|
||
name: call.toolCall?.name ?? '工具',
|
||
headline: toolHeadline(call.toolCall?.name ?? '', call.toolCall?.arguments),
|
||
arguments: call.toolCall?.arguments,
|
||
output: face.toolOutput,
|
||
phase,
|
||
durationMs: call.toolCall?.durationMs,
|
||
status: status === 'ok' || status === 'error' ? status : undefined,
|
||
summary: res?.toolResult?.summary,
|
||
running: phase === 'composing' || phase === 'running',
|
||
}
|
||
}
|
||
|
||
/** 把时间线部件收成行:相邻同类工具合并,思考是否仍在进行由 live 与后续部件决定。 */
|
||
export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] {
|
||
const out: TimelineEntry[] = []
|
||
let i = 0
|
||
while (i < parts.length) {
|
||
const p = parts[i]
|
||
if (p.type === 'tool_call') {
|
||
const kind = toolKind(p.toolCall?.name ?? '')
|
||
if (SOLO.has(kind)) {
|
||
const call = parts[i]
|
||
i++
|
||
let res: Part | undefined
|
||
if (parts[i]?.type === 'tool_result') { res = parts[i]; i++ }
|
||
out.push({ kind: 'toolblock', toolKind: kind, item: toolItem(call, res, live) })
|
||
continue
|
||
}
|
||
const items: ToolGroupItem[] = []
|
||
while (i < parts.length && parts[i].type === 'tool_call' && toolKind(parts[i].toolCall?.name ?? '') === kind) {
|
||
const call = parts[i]
|
||
i++
|
||
let res: Part | undefined
|
||
if (parts[i]?.type === 'tool_result') { res = parts[i]; i++ }
|
||
items.push(toolItem(call, res, live))
|
||
}
|
||
out.push({ kind: 'tools', toolKind: kind, items })
|
||
continue
|
||
}
|
||
if (p.type === 'tool_result') { i++; continue }
|
||
if (p.type === 'thinking') {
|
||
out.push({ kind: 'thinking', text: p.text ?? '', durationMs: p.durationMs, active: false })
|
||
i++
|
||
continue
|
||
}
|
||
if (p.type === 'text') {
|
||
out.push({ kind: 'text', text: p.text ?? '' })
|
||
i++
|
||
continue
|
||
}
|
||
if (p.type === 'check') {
|
||
out.push({ kind: 'check', passed: !!p.check?.passed, reason: p.check?.reason ?? '' })
|
||
i++
|
||
continue
|
||
}
|
||
out.push({ kind: 'other', part: p })
|
||
i++
|
||
}
|
||
if (live) {
|
||
const idx = out.reduce((acc, e, n) => (e.kind === 'thinking' ? n : acc), -1)
|
||
const entry = idx >= 0 ? out[idx] : undefined
|
||
if (entry && entry.kind === 'thinking' && out.slice(idx + 1).every(e => e.kind === 'thinking')) {
|
||
entry.active = true
|
||
}
|
||
}
|
||
return out
|
||
}
|