Files
Agent-System-Frontend/src/toolkind.ts
T
weichenandCursor 794c3e1657 Show the final reply apart from the work timeline
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>
2026-10-02 13:41:36 +00:00

190 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 工具调用按类别合并成时间线的一行。新增类别必须补进 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
}