diff --git a/web/src/api/protocol.ts b/web/src/api/protocol.ts index e2c368aa..69c4164e 100644 --- a/web/src/api/protocol.ts +++ b/web/src/api/protocol.ts @@ -417,25 +417,6 @@ export interface TodoEventPayload { export type ListSessionTodosResult = RPCResult; export type GetRuntimeSnapshotResult = RPCResult; -export interface VerificationStartedPayload { - completion_passed: boolean; - completion_blocked_reason?: string; -} - -export interface VerificationStageFinishedPayload { - name: string; - status: string; - summary?: string; - reason?: string; - error_class?: string; -} - -export interface VerificationFinishedPayload { - acceptance_status: string; - stop_reason?: string; - error_class?: string; -} - export interface VerificationCompletedPayload { stop_reason?: string; } diff --git a/web/src/components/chat/MessageItem.test.tsx b/web/src/components/chat/MessageItem.test.tsx index cfe3db05..c44f293b 100644 --- a/web/src/components/chat/MessageItem.test.tsx +++ b/web/src/components/chat/MessageItem.test.tsx @@ -3,7 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import MessageItem from './MessageItem' vi.mock('./ToolCallCard', () => ({ default: () =>
tool-card
})) -vi.mock('./VerificationMessage', () => ({ default: () =>
verification-card
})) vi.mock('./AcceptanceMessage', () => ({ default: () =>
acceptance-card
})) vi.mock('./CodeBlock', () => ({ default: ({ code }: { code: string }) =>
{code}
})) vi.mock('./MarkdownContent', () => ({ default: ({ content }: { content: string }) => {content} })) @@ -30,11 +29,9 @@ describe('MessageItem', () => { expect(screen.getByText('reasoning')).toBeInTheDocument() }) - it('renders tool/verification/acceptance delegates', () => { + it('renders tool and acceptance delegates', () => { const { rerender } = render() expect(screen.getByText('tool-card')).toBeInTheDocument() - rerender() - expect(screen.getByText('verification-card')).toBeInTheDocument() rerender() expect(screen.getByText('acceptance-card')).toBeInTheDocument() }) diff --git a/web/src/components/chat/MessageItem.tsx b/web/src/components/chat/MessageItem.tsx index 55cf41c8..b2173e1d 100644 --- a/web/src/components/chat/MessageItem.tsx +++ b/web/src/components/chat/MessageItem.tsx @@ -1,7 +1,6 @@ import { memo, useState } from 'react' import { type ChatMessage } from '@/stores/useChatStore' import ToolCallCard from './ToolCallCard' -import VerificationMessage from './VerificationMessage' import AcceptanceMessage from './AcceptanceMessage' import CodeBlock from './CodeBlock' import MarkdownContent from './MarkdownContent' @@ -32,10 +31,6 @@ const MessageItem = memo(function MessageItem({ message, isLast = false, grouped return } - if (message.type === 'verification') { - return - } - if (message.type === 'acceptance') { return } diff --git a/web/src/components/chat/VerificationMessage.test.tsx b/web/src/components/chat/VerificationMessage.test.tsx deleted file mode 100644 index dadfb094..00000000 --- a/web/src/components/chat/VerificationMessage.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' -import VerificationMessage from './VerificationMessage' - -describe('VerificationMessage', () => { - it('renders running summary and stage details', () => { - render( - , - ) - expect(screen.getByText(/Verify running/)).toBeInTheDocument() - fireEvent.click(screen.getByRole('button')) - expect(screen.getByText('test')).toBeInTheDocument() - }) -}) - diff --git a/web/src/components/chat/VerificationMessage.tsx b/web/src/components/chat/VerificationMessage.tsx deleted file mode 100644 index a22c8645..00000000 --- a/web/src/components/chat/VerificationMessage.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useState, memo } from 'react' -import { type ChatMessage } from '@/stores/useChatStore' -import { ChevronRight, CheckCircle2, XCircle, Loader2, MinusCircle } from 'lucide-react' - -interface VerificationMessageProps { - message: ChatMessage - /** 是否与上一条 AI/工具消息属于同一回合 */ - groupedWithPrev?: boolean -} - -/** 聊天流内的 Verification 折叠摘要卡 —— 沿用 ThinkingMessage 折叠模式 */ -const VerificationMessage = memo(function VerificationMessage({ - message, - groupedWithPrev = false, -}: VerificationMessageProps) { - const [expanded, setExpanded] = useState(false) - const data = message.verificationData - if (!data) return null - - const stages = Object.values(data.stages) - const status = data.status - const isRunning = status === 'running' - const isFailed = status === 'failed' - - const passedCount = stages.filter((s) => s.status === 'pass').length - const totalCount = stages.length - - let headText = '' - if (isRunning) { - headText = `Verify running… (${passedCount}/${totalCount})` - } else if (isFailed) { - const firstFailed = stages.find((s) => s.status === 'fail') - headText = firstFailed - ? `Verify failed at ${firstFailed.name}` - : `Verify failed (${passedCount}/${totalCount} passed)` - } else { - headText = `Verify ${status === 'completed' ? 'completed' : 'finished'} (${passedCount}/${totalCount} passed)` - } - - return ( -
- {groupedWithPrev ? ( -
- ) : ( -
- -
- )} -
- - - {expanded && ( -
- {stages.length === 0 ? ( -
暂无 stage 数据
- ) : ( - stages.map((stage) => ( -
- - {stage.name} - {stage.summary && ( - {stage.summary} - )} - {stage.reason && {stage.reason}} -
- )) - )} -
- )} -
-
- ) -}) - -function StageIcon({ status }: { status: string }) { - if (status === 'pass') return - if (status === 'fail') return - if (status === 'soft_block') return - if (status === 'hard_block') return - return -} - -const styles: Record = { - row: { - display: 'flex', - gap: 10, - padding: '6px 0', - }, - rowGrouped: { - display: 'flex', - gap: 10, - padding: '2px 0', - }, - avatarSpacer: { - width: 28, - flexShrink: 0, - }, - aiAvatar: { - width: 28, - height: 28, - borderRadius: 'var(--radius-md)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - marginTop: 2, - }, - aiContent: { - flex: 1, - minWidth: 0, - }, - head: { - display: 'flex', - alignItems: 'center', - gap: 6, - padding: '4px 8px', - borderRadius: 'var(--radius-sm)', - border: 'none', - background: 'var(--bg-tertiary)', - color: 'var(--text-secondary)', - fontSize: 12, - cursor: 'pointer', - fontFamily: 'var(--font-ui)', - textAlign: 'left', - width: '100%', - }, - chevron: { - display: 'flex', - color: 'var(--text-tertiary)', - transition: 'transform 0.15s', - flexShrink: 0, - }, - label: { - fontWeight: 500, - }, - detail: { - padding: '8px 10px', - borderRadius: 'var(--radius-md)', - background: 'var(--bg-tertiary)', - marginTop: 4, - display: 'flex', - flexDirection: 'column', - gap: 6, - }, - stageRow: { - display: 'flex', - alignItems: 'center', - gap: 8, - fontSize: 12, - fontFamily: 'var(--font-ui)', - }, - stageName: { - fontWeight: 500, - color: 'var(--text-primary)', - minWidth: 80, - }, - stageSummary: { - color: 'var(--text-secondary)', - flex: 1, - }, - stageReason: { - color: 'var(--error)', - fontSize: 11, - }, - empty: { - color: 'var(--text-tertiary)', - fontSize: 12, - fontStyle: 'italic', - }, -} - -export default VerificationMessage \ No newline at end of file diff --git a/web/src/stores/useChatStore.ts b/web/src/stores/useChatStore.ts index 70f7ed4b..8e8f391c 100644 --- a/web/src/stores/useChatStore.ts +++ b/web/src/stores/useChatStore.ts @@ -5,7 +5,6 @@ import { type AcceptanceDecidedPayload, type PendingUserQuestionSnapshot, } from '@/api/protocol' -import { type VerificationRunRecord } from '@/stores/useRuntimeInsightStore' import { resetEventBridgeCursors } from '@/utils/eventBridge' /** 聊天消息 */ @@ -13,8 +12,8 @@ export interface ChatMessage { id: string /** 消息角色:user / assistant / tool */ role: 'user' | 'assistant' | 'tool' - /** 消息类型:text / thinking / tool_call / code / welcome / system / verification / acceptance */ - type: 'text' | 'thinking' | 'tool_call' | 'code' | 'welcome' | 'system' | 'verification' | 'acceptance' + /** 消息类型:text / thinking / tool_call / code / welcome / system / acceptance */ + type: 'text' | 'thinking' | 'tool_call' | 'code' | 'welcome' | 'system' | 'acceptance' /** 文本内容 */ content: string /** 工具调用信息 */ @@ -23,11 +22,9 @@ export interface ChatMessage { toolArgs?: string toolResult?: string toolStatus?: 'running' | 'done' | 'error' - /** Verification 摘要数据(仅 type === 'verification' 使用) */ - verificationData?: VerificationRunRecord - /** Acceptance 决策数据(仅 type === 'acceptance' 使用) */ + /** Acceptance 决策数据,仅 `type === 'acceptance'` 时使用 */ acceptanceData?: AcceptanceDecidedPayload - /** Thinking 数据(仅 type === 'thinking' 使用) */ + /** Thinking 附加数据,仅 `type === 'thinking'` 时使用 */ thinkingData?: { collapsed: boolean } /** 代码语言 */ language?: string @@ -45,19 +42,19 @@ interface ChatState { messages: ChatMessage[] /** 是否正在生成 */ isGenerating: boolean - /** 当前会话是否正在执行上下文压缩。 */ + /** 当前会话是否正在执行上下文压缩 */ isCompacting: boolean - /** 当前压缩触发模式,用于展示压缩来源。 */ + /** 当前压缩触发模式,用于展示压缩来源 */ compactMode: string - /** 压缩期间展示给用户的持续状态文案。 */ + /** 压缩期间展示给用户的持续状态文案 */ compactMessage: string - /** 当前 AI 回复缓冲 ID(流式追加用) */ + /** 当前 AI 回复缓冲 ID,用于流式追加 */ streamingMessageId: string /** 当前 thinking 流式消息 ID */ streamingThinkingMessageId: string /** 权限请求列表 */ permissionRequests: PermissionRequestPayload[] - /** 当前待回答 ask_user 问题(v1 单活跃) */ + /** 当前待回答 ask_user 问题 */ pendingUserQuestion: PendingUserQuestionSnapshot | null /** Token 用量 */ tokenUsage: TokenUsage | null @@ -65,7 +62,7 @@ interface ChatState { phase: string /** 停止原因 */ stopReason: string - /** 会话切换中标记(eventBridge 据此丢弃中间窗口期事件) */ + /** 会话切换中标记,eventBridge 据此丢弃中间窗口期事件 */ isTransitioning: boolean /** 当前 Agent 工作模式 */ agentMode: 'build' | 'plan' @@ -76,29 +73,27 @@ interface ChatState { addMessage: (msg: ChatMessage) => void setMessages: (messages: ChatMessage[]) => void removeMessage: (id: string) => void - /** 从指定消息(含)开始截断 messages 数组并清理生成相关状态 */ + /** 从指定消息开始截断 messages,并清理生成相关状态 */ truncateFromMessage: (messageId: string) => void appendChunk: (text: string) => void - /** 原子操作:创建流式 assistant 消息 + 加入列表 + 设置 streamingMessageId */ + /** 原子操作:创建流式 assistant 消息并设置 streamingMessageId */ startStreamingMessage: () => string finalizeMessage: (id: string, content: string) => void /** 创建流式 thinking 消息并设置 streamingThinkingMessageId */ startThinkingMessage: () => string - /** 追加 thinking 文本到当前流式 thinking 消息 */ + /** 追加 thinking 文本到当前流式消息 */ appendThinkingChunk: (text: string) => void - /** 终结 thinking 消息(collapsed=true)并清空 streamingThinkingMessageId */ + /** 终结 thinking 消息并清空 streamingThinkingMessageId */ finalizeThinkingMessage: () => void updateToolCall: (toolCallId: string, result: string, status: ChatMessage['toolStatus']) => boolean appendToolOutput: (toolCallId: string, chunk: string) => void - /** 将所有运行中的工具条目标记为指定状态,用于终止事件兜底收敛 UI。 */ + /** 将所有运行中的工具条目标记为指定状态,用于终止事件兜底收敛 UI */ finalizeRunningToolCalls: (status: 'done' | 'error') => void - /** 更新一条 verification 消息的 data(verification 进行中持续更新同一条消息) */ - updateVerificationMessage: (messageId: string, data: VerificationRunRecord) => void setGenerating: (v: boolean) => void startCompacting: (mode?: string, message?: string) => void finishCompacting: () => void setStreamingMessageId: (id: string) => void - /** 重置生成状态:终结当前流式消息 + 清除 isGenerating */ + /** 重置生成状态:终结当前流式消息并清除 isGenerating */ resetGeneratingState: () => void setTransitioning: (v: boolean) => void addPermissionRequest: (req: PermissionRequestPayload) => void @@ -142,7 +137,7 @@ export function createAssistantMessage(): ChatMessage { } } -/** 创建系统消息(用于展示 slash command 执行结果) */ +/** 创建系统消息,用于展示 slash command 执行结果 */ export function createSystemMessage(text: string): ChatMessage { return { id: nextMsgId(), @@ -231,7 +226,7 @@ export const useChatStore = create((set) => ({ } }), - /** 原子操作:创建消息 + 加入列表 + 设置 streamingMessageId,避免竞态 */ + /** 原子操作:创建消息并设置 streamingMessageId,避免竞态 */ startStreamingMessage: () => { const msg = createAssistantMessage() set((s) => ({ @@ -314,15 +309,6 @@ export const useChatStore = create((set) => ({ ), })), - updateVerificationMessage: (messageId, data) => - set((s) => ({ - messages: s.messages.map((m) => - m.id === messageId && m.type === 'verification' - ? { ...m, verificationData: data } - : m - ), - })), - setGenerating: (isGenerating) => set({ isGenerating }), startCompacting: (compactMode = 'manual', compactMessage = 'Compacting context...') => set({ @@ -338,7 +324,7 @@ export const useChatStore = create((set) => ({ }), setStreamingMessageId: (streamingMessageId) => set({ streamingMessageId }), - /** 重置生成状态:终结当前流式消息 + 清除 isGenerating */ + /** 重置生成状态:终结当前流式消息并清除 isGenerating */ resetGeneratingState: () => set((s) => { let msgs = s.messages @@ -402,7 +388,7 @@ export const useChatStore = create((set) => ({ setAgentMode: (agentMode) => set({ agentMode }), setPermissionMode: (permissionMode) => set({ permissionMode }), - /** 清理全部聊天状态,包括权限请求、token用量等。同时重置 eventBridge 模块级游标,避免跨会话泄漏。 */ + /** 清理全部聊天状态,并重置 eventBridge 游标,避免跨会话泄漏 */ clearMessages: () => { resetEventBridgeCursors() set({ diff --git a/web/src/stores/useRuntimeInsightStore.test.ts b/web/src/stores/useRuntimeInsightStore.test.ts index 15766519..44ea6376 100644 --- a/web/src/stores/useRuntimeInsightStore.test.ts +++ b/web/src/stores/useRuntimeInsightStore.test.ts @@ -6,16 +6,6 @@ beforeEach(() => { }) describe('useRuntimeInsightStore', () => { - it('upserts verification stages by name', () => { - const store = useRuntimeInsightStore.getState() - - store.upsertVerificationStage({ name: 'test', status: 'failed', reason: 'first' }) - store.upsertVerificationStage({ name: 'test', status: 'passed', summary: 'ok' }) - - expect(useRuntimeInsightStore.getState().verificationStages.test.status).toBe('passed') - expect(useRuntimeInsightStore.getState().verificationStages.test.summary).toBe('ok') - }) - it('calculates budget usage ratio when prompt budget is available', () => { useRuntimeInsightStore.getState().setBudgetChecked({ attempt_seq: 1, @@ -40,39 +30,53 @@ describe('useRuntimeInsightStore', () => { expect(useRuntimeInsightStore.getState().budgetUsageRatio).toBeNull() }) - it('resets all insight state', () => { + it('stores final verification outcomes', () => { const store = useRuntimeInsightStore.getState() - store.setAcceptanceDecision({ status: 'accepted', user_visible_summary: 'done' }) - store.setTodoSnapshot({ summary: { total: 1, required_total: 1, required_completed: 1, required_failed: 0, required_open: 0 } }) - store.reset() + store.completeVerification({ stop_reason: 'accepted' }) + expect(useRuntimeInsightStore.getState().verificationCompleted?.stop_reason).toBe('accepted') - expect(useRuntimeInsightStore.getState().acceptanceDecision).toBeNull() - expect(useRuntimeInsightStore.getState().todoSnapshot).toBeNull() + store.failVerification({ stop_reason: 'error', error_class: 'TestError' }) + expect(useRuntimeInsightStore.getState().verificationFailed?.error_class).toBe('TestError') + expect(useRuntimeInsightStore.getState().verificationCompleted).toBeNull() }) - it('failVerification updates history status', () => { + it('clears a stale failed terminal state when verification later completes', () => { const store = useRuntimeInsightStore.getState() - store.startVerification({ completion_passed: true }) + store.failVerification({ stop_reason: 'error', error_class: 'TestError' }) - expect(useRuntimeInsightStore.getState().verificationHistory[0].status).toBe('failed') + store.completeVerification({ stop_reason: 'accepted' }) + + const state = useRuntimeInsightStore.getState() + expect(state.verificationCompleted?.stop_reason).toBe('accepted') + expect(state.verificationFailed).toBeNull() }) - it('setTodoSnapshot clears any stale todoConflict on a valid update', () => { + it('clears a stale completed terminal state when verification later fails', () => { const store = useRuntimeInsightStore.getState() - store.setTodoConflict({ action: 'todo_conflict', reason: 'todo_not_found' }) - expect(useRuntimeInsightStore.getState().todoConflict?.reason).toBe('todo_not_found') - store.setTodoSnapshot({ - items: [{ id: 'a', content: 'task', status: 'pending', required: true, revision: 1 }], - summary: { total: 1, required_total: 1, required_completed: 0, required_failed: 0, required_open: 1 }, - }) + store.completeVerification({ stop_reason: 'accepted' }) + store.failVerification({ stop_reason: 'error', error_class: 'TestError' }) - expect(useRuntimeInsightStore.getState().todoConflict).toBeNull() - expect(useRuntimeInsightStore.getState().todoSnapshot?.items?.[0].id).toBe('a') + const state = useRuntimeInsightStore.getState() + expect(state.verificationFailed?.error_class).toBe('TestError') + expect(state.verificationCompleted).toBeNull() }) - it('setTodoSnapshot clears conflict on valid update', () => { + it('resets all insight state', () => { + const store = useRuntimeInsightStore.getState() + store.setAcceptanceDecision({ status: 'accepted', user_visible_summary: 'done' }) + store.completeVerification({ stop_reason: 'accepted' }) + store.setTodoSnapshot({ summary: { total: 1, required_total: 1, required_completed: 1, required_failed: 0, required_open: 0 } }) + + store.reset() + + expect(useRuntimeInsightStore.getState().acceptanceDecision).toBeNull() + expect(useRuntimeInsightStore.getState().verificationCompleted).toBeNull() + expect(useRuntimeInsightStore.getState().todoSnapshot).toBeNull() + }) + + it('setTodoSnapshot clears any stale todoConflict on a valid update', () => { const store = useRuntimeInsightStore.getState() store.setTodoConflict({ action: 'todo_conflict', reason: 'todo_not_found' }) expect(useRuntimeInsightStore.getState().todoConflict?.reason).toBe('todo_not_found') @@ -105,7 +109,7 @@ describe('useRuntimeInsightStore', () => { expect(state.todoConflict).toBeNull() }) - it('applyTodoSnapshot updates snapshot but does NOT clear conflict', () => { + it('applyTodoSnapshot updates snapshot but does not clear conflict', () => { const store = useRuntimeInsightStore.getState() store.setTodoConflict({ action: 'todo_conflict', reason: 'revision_conflict' }) expect(useRuntimeInsightStore.getState().todoConflict?.reason).toBe('revision_conflict') @@ -117,7 +121,6 @@ describe('useRuntimeInsightStore', () => { const state = useRuntimeInsightStore.getState() expect(state.todoSnapshot?.items?.[0].id).toBe('b') - // conflict must be preserved expect(state.todoConflict?.reason).toBe('revision_conflict') }) diff --git a/web/src/stores/useRuntimeInsightStore.ts b/web/src/stores/useRuntimeInsightStore.ts index 5f99c640..911dc4f5 100644 --- a/web/src/stores/useRuntimeInsightStore.ts +++ b/web/src/stores/useRuntimeInsightStore.ts @@ -14,25 +14,8 @@ import { type TodoViewItem, type VerificationCompletedPayload, type VerificationFailedPayload, - type VerificationFinishedPayload, - type VerificationStageFinishedPayload, - type VerificationStartedPayload, } from '@/api/protocol' -/** 单次 verification 跑批的归档记录,用于 InsightPanel 的历史 tab 与聊天流内联折叠卡共享 */ -export interface VerificationRunRecord { - id: string - startedAt: number - finishedAt?: number - started: VerificationStartedPayload - stages: Record - finished?: VerificationFinishedPayload - completed?: VerificationCompletedPayload - failed?: VerificationFailedPayload - status: 'running' | 'finished' | 'completed' | 'failed' -} - -/** 会话内 todo 累积历史:每次 snapshot 合并写入,旧条目即使被新 snapshot 移除也保留 */ export interface TodoHistoryEntry extends TodoViewItem { lastSeenAt: number firstSeenAt: number @@ -42,14 +25,8 @@ interface RuntimeInsightState { checkpointDiff: CheckpointDiffResultPayload | null checkpointEvents: Array checkpointWarning: CheckpointWarningPayload | null - verificationRunning: boolean - verificationStarted: VerificationStartedPayload | null - verificationStages: Record - verificationFinished: VerificationFinishedPayload | null verificationCompleted: VerificationCompletedPayload | null verificationFailed: VerificationFailedPayload | null - /** 历史归档:每次 VerificationStarted 追加一条 record,后续 stage/finished/completed/failed 写入末尾 */ - verificationHistory: VerificationRunRecord[] acceptanceDecision: AcceptanceDecidedPayload | null todoSnapshot: TodoSnapshot | null todoEvents: TodoEventPayload[] @@ -63,15 +40,10 @@ interface RuntimeInsightState { setCheckpointDiff: (diff: CheckpointDiffResultPayload | null) => void addCheckpointEvent: (event: CheckpointCreatedPayload | CheckpointRestoredPayload | CheckpointUndoRestorePayload) => void setCheckpointWarning: (warning: CheckpointWarningPayload | null) => void - startVerification: (payload: VerificationStartedPayload) => string - upsertVerificationStage: (payload: VerificationStageFinishedPayload) => void - finishVerification: (payload: VerificationFinishedPayload) => void completeVerification: (payload: VerificationCompletedPayload) => void failVerification: (payload: VerificationFailedPayload) => void setAcceptanceDecision: (payload: AcceptanceDecidedPayload | null) => void - /** 成功事件更新快照并清除冲突(用于 todo_updated / todo_summary_injected) */ setTodoSnapshot: (snapshot: TodoSnapshot | null) => void - /** 仅更新快照,保留冲突状态(用于 todo_snapshot_updated) */ applyTodoSnapshot: (snapshot: TodoSnapshot | null) => void addTodoEvent: (event: TodoEventPayload) => void setTodoConflict: (event: TodoEventPayload | null) => void @@ -85,13 +57,8 @@ const initialState = { checkpointDiff: null as CheckpointDiffResultPayload | null, checkpointEvents: [] as Array, checkpointWarning: null as CheckpointWarningPayload | null, - verificationRunning: false, - verificationStarted: null as VerificationStartedPayload | null, - verificationStages: {} as Record, - verificationFinished: null as VerificationFinishedPayload | null, verificationCompleted: null as VerificationCompletedPayload | null, verificationFailed: null as VerificationFailedPayload | null, - verificationHistory: [] as VerificationRunRecord[], acceptanceDecision: null as AcceptanceDecidedPayload | null, todoSnapshot: null as TodoSnapshot | null, todoEvents: [] as TodoEventPayload[], @@ -108,84 +75,20 @@ function calculateBudgetUsageRatio(payload: BudgetCheckedPayload): number | null return payload.estimated_input_tokens / payload.prompt_budget } -let _verificationCounter = 0 -function nextVerificationId(): string { - return `vrun_${Date.now()}_${++_verificationCounter}` -} - -/** 把 updater 应用到 history 的最后一条 record(若存在) */ -function patchLatestVerification( - history: VerificationRunRecord[], - updater: (record: VerificationRunRecord) => VerificationRunRecord, -): VerificationRunRecord[] { - if (history.length === 0) return history - const next = history.slice() - next[next.length - 1] = updater(next[next.length - 1]) - return next -} - export const useRuntimeInsightStore = create((set) => ({ ...initialState, setCheckpointDiff: (checkpointDiff) => set({ checkpointDiff }), addCheckpointEvent: (event) => set((s) => ({ checkpointEvents: [...s.checkpointEvents, event] })), setCheckpointWarning: (checkpointWarning) => set({ checkpointWarning }), - startVerification: (verificationStarted) => { - const record: VerificationRunRecord = { - id: nextVerificationId(), - startedAt: Date.now(), - started: verificationStarted, - stages: {}, - status: 'running', - } - set((s) => ({ - verificationRunning: true, - verificationStarted, - verificationStages: {}, - verificationFinished: null, - verificationCompleted: null, - verificationFailed: null, - verificationHistory: [...s.verificationHistory, record].slice(-50), - })) - return record.id - }, - upsertVerificationStage: (stage) => set((s) => ({ - verificationStages: { ...s.verificationStages, [stage.name]: stage }, - verificationHistory: patchLatestVerification(s.verificationHistory, (record) => ({ - ...record, - stages: { ...record.stages, [stage.name]: stage }, - })), - })), - finishVerification: (verificationFinished) => set((s) => ({ - verificationRunning: false, - verificationFinished, - verificationHistory: patchLatestVerification(s.verificationHistory, (record) => ({ - ...record, - finished: verificationFinished, - finishedAt: Date.now(), - status: 'finished', - })), - })), - completeVerification: (verificationCompleted) => set((s) => ({ - verificationRunning: false, + completeVerification: (verificationCompleted) => set({ verificationCompleted, - verificationHistory: patchLatestVerification(s.verificationHistory, (record) => ({ - ...record, - completed: verificationCompleted, - finishedAt: record.finishedAt ?? Date.now(), - status: 'completed', - })), - })), - failVerification: (verificationFailed) => set((s) => ({ - verificationRunning: false, + verificationFailed: null, + }), + failVerification: (verificationFailed) => set({ verificationFailed, - verificationHistory: patchLatestVerification(s.verificationHistory, (record) => ({ - ...record, - failed: verificationFailed, - finishedAt: record.finishedAt ?? Date.now(), - status: 'failed', - })), - })), + verificationCompleted: null, + }), setAcceptanceDecision: (acceptanceDecision) => set({ acceptanceDecision }), setTodoSnapshot: (todoSnapshot) => set((s) => { const items = todoSnapshot?.items ?? [] diff --git a/web/src/utils/eventBridge.test.ts b/web/src/utils/eventBridge.test.ts index 6e732387..c7710c37 100644 --- a/web/src/utils/eventBridge.test.ts +++ b/web/src/utils/eventBridge.test.ts @@ -804,7 +804,7 @@ describe("eventBridge", () => { payload: { payload: { runtime_event_type: EventType.TokenUsage, - payload: { input_tokens: 3, output_tokens: 5, total_tokens: 8 }, + payload: { input_tokens: 3, output_tokens: 5, session_input_tokens: 3, session_output_tokens: 5 }, }, }, session_id: "sess-1", @@ -812,7 +812,7 @@ describe("eventBridge", () => { }, api, ); - expect(useChatStore.getState().tokenUsage?.total_tokens).toBe(8); + expect(useChatStore.getState().tokenUsage?.output_tokens).toBe(5); handleGatewayEvent( { @@ -820,7 +820,7 @@ describe("eventBridge", () => { payload: { payload: { runtime_event_type: EventType.BudgetEstimateFailed, - payload: { reason: "missing_price", detail: "no price rule" }, + payload: { attempt_seq: 1, request_hash: "hash-1", message: "no price rule" }, }, }, session_id: "sess-1", @@ -828,8 +828,8 @@ describe("eventBridge", () => { }, api, ); - expect(useRuntimeInsightStore.getState().budgetEstimateFailed?.reason).toBe( - "missing_price", + expect(useRuntimeInsightStore.getState().budgetEstimateFailed?.message).toBe( + "no price rule", ); handleGatewayEvent( @@ -838,7 +838,15 @@ describe("eventBridge", () => { payload: { payload: { runtime_event_type: EventType.LedgerReconciled, - payload: { estimated_cost_usd: 1, actual_cost_usd: 0.8, delta_usd: -0.2 }, + payload: { + attempt_seq: 1, + request_hash: "hash-1", + input_tokens: 3, + input_source: "observed", + output_tokens: 5, + output_source: "observed", + has_unknown_usage: false, + }, }, }, session_id: "sess-1", @@ -846,8 +854,8 @@ describe("eventBridge", () => { }, api, ); - expect(useRuntimeInsightStore.getState().ledgerReconciled?.actual_cost_usd).toBe( - 0.8, + expect(useRuntimeInsightStore.getState().ledgerReconciled?.output_tokens).toBe( + 5, ); }); @@ -1007,7 +1015,6 @@ describe("eventBridge", () => { ); }); - it("AcceptanceDecided stores acceptance decision", () => { const api = createMockGatewayAPI(); handleGatewayEvent( @@ -1030,6 +1037,53 @@ describe("eventBridge", () => { ); }); + it("VerificationCompleted stores final verification outcome", () => { + const api = createMockGatewayAPI(); + handleGatewayEvent( + { + type: EventType.VerificationCompleted, + payload: { + payload: { + runtime_event_type: EventType.VerificationCompleted, + payload: { stop_reason: "accepted" }, + }, + }, + session_id: "sess-1", + run_id: "run-1", + }, + api, + ); + + expect( + useRuntimeInsightStore.getState().verificationCompleted?.stop_reason, + ).toBe("accepted"); + expect(useChatStore.getState().messages).toHaveLength(0); + }); + + it("VerificationFailed stores final verification failure without creating verification chat message", () => { + const api = createMockGatewayAPI(); + handleGatewayEvent( + { + type: EventType.VerificationFailed, + payload: { + payload: { + runtime_event_type: EventType.VerificationFailed, + payload: { stop_reason: "error", error_class: "TestError" }, + }, + }, + session_id: "sess-1", + run_id: "run-1", + }, + api, + ); + + expect( + useRuntimeInsightStore.getState().verificationFailed?.error_class, + ).toBe("TestError"); + expect(useUIStore.getState().toasts.at(-1)?.message).toBe("TestError"); + expect(useChatStore.getState().messages).toHaveLength(0); + }); + it("TodoSnapshotUpdated stores todo snapshot", () => { const api = createMockGatewayAPI(); handleGatewayEvent( @@ -1838,9 +1892,6 @@ describe("eventBridge", () => { }); }); - - - it("AcceptanceDecided creates an acceptance ChatMessage", () => { const api = createMockGatewayAPI(); handleGatewayEvent( diff --git a/web/src/utils/eventBridge.ts b/web/src/utils/eventBridge.ts index 0dcef5d2..925b6090 100644 --- a/web/src/utils/eventBridge.ts +++ b/web/src/utils/eventBridge.ts @@ -39,7 +39,6 @@ import { type PayloadRecord = Record | undefined; // 模块级缓存最新 verification 消息 ID,避免每次 verification stage 事件都全量扫描 messages 数组。 -let _latestVerificationMsgId: string | undefined; // 模块级缓存最新的 checkpoint_id,用于文件变更面板关联后续端到端 diff。 let _latestCheckpointId: string | undefined; @@ -58,7 +57,6 @@ const CHECKPOINT_REASON_PRE_RESTORE_GUARD = "pre_restore_guard"; /** 重置模块级游标 —— 在截断聊天历史 / 切换会话等场景调用,避免后续事件挂到已被移除的消息上 */ export function resetEventBridgeCursors() { const keepCheckpointBaseline = useUIStore.getState().isRestoringCheckpoint; - _latestVerificationMsgId = undefined; _latestCheckpointId = keepCheckpointBaseline ? _latestCheckpointId : undefined; @@ -662,16 +660,6 @@ export function handleGatewayEvent( const insightStore = useRuntimeInsightStore.getState(); /** 更新最新 verification 消息的 data 为 insightStore 当前最后一条 record */ - function syncLatestVerificationToChat() { - const history = useRuntimeInsightStore.getState().verificationHistory; - if (_latestVerificationMsgId && history.length > 0) { - chatStore.updateVerificationMessage( - _latestVerificationMsgId, - history[history.length - 1], - ); - } - } - switch (eventType) { case EventType.ThinkingDelta: { const text = eventPayload as string | undefined; @@ -1011,7 +999,6 @@ export function handleGatewayEvent( const payload = eventPayload as VerificationCompletedPayload | undefined; if (payload) { insightStore.completeVerification(payload); - syncLatestVerificationToChat(); } break; } @@ -1020,7 +1007,6 @@ export function handleGatewayEvent( const payload = eventPayload as VerificationFailedPayload | undefined; if (payload) { insightStore.failVerification(payload); - syncLatestVerificationToChat(); } uiStore.showToast( strField(eventPayload, "error_class") || "Verification failed",