From 794c3e165721d374948fa5d7fde843f0c92105a1 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 2 Oct 2026 13:41:36 +0000 Subject: [PATCH] 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 --- src/components/Conversation.tsx | 4 ++-- src/components/ProcessTimeline.tsx | 4 ++++ src/contracts.ts | 8 +++++--- src/live.ts | 30 +++++++++++++++++++++++++----- src/store.ts | 12 ++++++++++-- src/styles.css | 1 + src/toolkind.ts | 6 ++++++ test/live.test.ts | 18 ++++++++++++++---- test/toolkind.test.ts | 11 +++++++++++ 9 files changed, 78 insertions(+), 16 deletions(-) diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index c4d7b1c..83972de 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -216,8 +216,8 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null useEffect(() => { if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true) }, [live, msg.runId]) - const extras = citedArtifactCards(split.final) - const cards = dedupeDeliverables(split.final) + const extras = live ? [] : citedArtifactCards(split.final) + const cards = live ? [] : dedupeDeliverables(split.final) const lastText = (() => { for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i return -1 diff --git a/src/components/ProcessTimeline.tsx b/src/components/ProcessTimeline.tsx index b906361..d2c8a56 100644 --- a/src/components/ProcessTimeline.tsx +++ b/src/components/ProcessTimeline.tsx @@ -33,6 +33,10 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: { if (row.kind === 'text') { return
{renderPart({ type: 'text', text: row.text }, false)}
} + if (row.kind === 'check') { + const text = row.passed ? '终轮检查通过' : `终轮检查未通过:${row.reason}` + return
{text}
+ } if (row.kind === 'toolblock') { return } diff --git a/src/contracts.ts b/src/contracts.ts index 425692e..1c64b32 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -23,7 +23,7 @@ export type Role = 'user' | 'assistant' | 'system'; export type PartType = | 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref' - | 'hitl' | 'plan' | 'error' | 'card'; + | 'hitl' | 'plan' | 'error' | 'card' | 'check'; export interface Part { type: PartType; @@ -35,6 +35,7 @@ export interface Part { plan?: PlanSnapshot; card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card) error?: { code: string; message: string }; + check?: { passed: boolean; reason?: string }; // 终轮自动检查,过程区一行 process?: boolean; // run 终态后折叠为摘要行 durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs } @@ -48,7 +49,7 @@ export type RunKind = 'conversation' | 'subagent' | 'tool'; export type RunStatus = | 'pending' | 'running' | 'waiting_hitl' | 'completed' | 'failed' | 'cancelled' | 'interrupted'; -export type RunPhase = 'thinking' | 'acting' | 'responding' | 'done'; +export type RunPhase = 'thinking' | 'acting' | 'responding' | 'finalizing' | 'done'; export interface Usage { promptTokens: number; completionTokens: number; totalTokens: number; @@ -82,7 +83,7 @@ export interface TodoList { export type StreamEventType = | 'message.delta' | 'thinking.delta' | 'tool.call.delta' | 'tool.call' | 'tool.output' | 'tool.result' - | 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update' + | 'run.phase' | 'run.check' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update' | 'agent.spawn' | 'agent.progress' | 'agent.result' | 'artifact.created' | 'artifact.updated' | 'run.completed' | 'run.failed' | 'error' @@ -112,6 +113,7 @@ export interface ContextObservation { truncations?: string[] estChars: number } +export interface RunCheckPayload { passed: boolean; reason?: string } export interface RunFailedPayload { code: string; message: string } // ---- 收件箱卡片 ---- diff --git a/src/live.ts b/src/live.ts index ff94eb8..c14015a 100644 --- a/src/live.ts +++ b/src/live.ts @@ -26,6 +26,8 @@ export type LiveItem = summary?: string } | { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } } + | { kind: 'check'; passed: boolean; reason: string } + | { kind: 'final' } export type LiveTool = Extract @@ -143,6 +145,16 @@ export function applyToolResult(items: LiveItem[], callId: string | undefined, s return items } +export function applyCheck(items: LiveItem[], passed: boolean, reason: string): LiveItem[] { + return [...items, { kind: 'check', passed, reason }] +} + +// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。 +export function applyFinalizing(items: LiveItem[]): LiveItem[] { + if (items.some(it => it.kind === 'final')) return items + return [...items, { kind: 'final' }] +} + export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] { return [...items, { kind: 'artifact', artifact }] } @@ -161,17 +173,26 @@ export function applyArtifactUpdated(items: LiveItem[], artifact: { artifactId: // liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息。 export function liveToMessage(live: LiveState): Message { + const finalAt = live.items.findIndex(it => it.kind === 'final') const textProcess = new Map() for (let i = 0; i < live.items.length; i++) { if (live.items[i].kind !== 'text') continue + if (finalAt >= 0) { + textProcess.set(i, i < finalAt) + continue + } let hasCalls = false for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true textProcess.set(i, hasCalls) } const parts: Part[] = [] - const deliverables: Part[] = [] for (let i = 0; i < live.items.length; i++) { const it = live.items[i] + if (it.kind === 'final' || it.kind === 'artifact') continue + if (it.kind === 'check') { + parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } }) + continue + } if (it.kind === 'thinking') { const durationMs = it.endedAt - it.startedAt if (it.text) parts.push({ @@ -196,21 +217,20 @@ export function liveToMessage(live: LiveState): Message { toolResult: { callId: call.id, status: it.status === 'error' ? 'error' : 'ok', summary: it.summary }, }) } - } else if (it.kind === 'artifact') { - deliverables.push({ type: 'artifact_ref', artifact: it.artifact }) } else { parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined }) } } - parts.push(...deliverables) return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' } } // livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。 export function livePhaseLabel(live: LiveState, now = Date.now()): string { + if (live.phase === 'finalizing') return '整理回复中' let last: LiveItem | undefined for (let i = live.items.length - 1; i >= 0; i--) { - if (live.items[i].kind !== 'artifact') { last = live.items[i]; break } + const kind = live.items[i].kind + if (kind !== 'artifact' && kind !== 'check' && kind !== 'final') { last = live.items[i]; break } } if (!last || last.kind === 'thinking') return '思考中' if (last.kind === 'tool') { diff --git a/src/store.ts b/src/store.ts index 5747bdc..ccba8e3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2,7 +2,7 @@ // useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。 import { useSyncExternalStore } from 'react' import { api, connectEvents } from './api' -import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, type LiveItem } from './live' +import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live' import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs' import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox' import { flashElement } from './scrollsync' @@ -376,7 +376,15 @@ export const store = { } case 'run.phase': { const p = e.payload as { phase: string } - set({ live: touchLive(e.runId ?? '', l => { l.phase = p.phase }) }) + set({ live: touchLive(e.runId ?? '', l => { + l.phase = p.phase + if (p.phase === 'finalizing') l.items = applyFinalizing(l.items) + }) }) + break + } + case 'run.check': { + const p = e.payload as { passed?: boolean; reason?: string } + set({ live: touchLive(e.runId ?? '', l => { l.items = applyCheck(l.items, !!p.passed, p.reason ?? '') }) }) break } case 'hitl.request': { diff --git a/src/styles.css b/src/styles.css index af403ad..b25e8c5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -191,6 +191,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .tl-tools-list li { margin: 1px 0; } .tl-tools-name { color: var(--text-dim); } .tl-tools-sum { margin-left: 8px; color: var(--text-faint); } +.tl-check { color: var(--text-faint); font-size: var(--fs-ui-sm); margin: 4px 0; } .tl-tools .status-error { margin-left: 8px; color: var(--err); } /* 对话右侧预览区 */ diff --git a/src/toolkind.ts b/src/toolkind.ts index e3686b2..9854ef5 100644 --- a/src/toolkind.ts +++ b/src/toolkind.ts @@ -104,6 +104,7 @@ export type TimelineEntry = | { 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 = new Set(['exec', 'write', 'delegate']) @@ -169,6 +170,11 @@ export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] { 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++ } diff --git a/test/live.test.ts b/test/live.test.ts index 6853bc9..6cf7d40 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest' import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, + applyCheck, applyFinalizing, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem, } from '../src/live' import type { LiveState } from '../src/store' @@ -135,8 +136,7 @@ describe('liveToMessage', () => { const msg = liveToMessage(live({ items })) for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true) }) - it('deliverable cards are content parts pinned after the reply text', () => { - // 与落库序同构(llm.go 交付物钉尾):artifact_ref 在终轮正文之后、不折叠。 + it('does not project deliverable cards while the run is live', () => { let items: LiveItem[] = [] items = applyTextDelta(items, 0, '生成报表。') items = applyToolCall(items, 'make_report', 'c1') @@ -144,10 +144,19 @@ describe('liveToMessage', () => { items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' }) items = applyTextDelta(items, 1, '完成。') const msg = liveToMessage(live({ items })) - expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'artifact_ref']) - expect(msg.parts.find(p => p.type === 'artifact_ref')?.process).toBeFalsy() // 内容部件,不折叠 + expect(msg.parts.some(p => p.type === 'artifact_ref')).toBe(false) expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined]) }) + it('moves earlier text into the process area once finalizing starts', () => { + let items: LiveItem[] = applyTextDelta([], 0, '草稿回复') + items = applyCheck(items, false, '纸张不是 A4') + items = applyFinalizing(items) + items = applyTextDelta(items, 1, '纪要已写好。') + const msg = liveToMessage(live({ phase: 'finalizing', items })) + expect(msg.parts.map(p => p.type)).toEqual(['text', 'check', 'text']) + expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined]) + expect(msg.parts[1]).toMatchObject({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }) + }) }) describe('livePhaseLabel', () => { @@ -161,6 +170,7 @@ describe('livePhaseLabel', () => { items = applyToolResult(items, 'c', 'ok', 'done', 4000) items = applyTextDelta(items, 1, '好了') expect(livePhaseLabel(live({ items }), 4000)).toBe('回复中') + expect(livePhaseLabel(live({ items, phase: 'finalizing' }), 4000)).toBe('整理回复中') }) }) diff --git a/test/toolkind.test.ts b/test/toolkind.test.ts index 52ce3bd..40d2846 100644 --- a/test/toolkind.test.ts +++ b/test/toolkind.test.ts @@ -54,6 +54,17 @@ describe('timelineEntries', () => { 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 })