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 })