From f74b27daa1f3ebc8294d8f16a58522b2b61e891a Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 12 Sep 2026 16:32:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(statusbar):=20=E7=94=9F=E4=BF=A1=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=204=20=E4=B8=AA=20agent=20=E7=82=B9=E4=B8=8D=E9=9A=8F?= =?UTF-8?q?=E7=9B=B8=E4=BD=8D=E7=82=B9=E4=BA=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:StatusBar/TopBar 只读 chat.currentMessage 的 pipeline 块 steps,生信对话 (sendBioflow)从不创建 pipeline 块——相位推进只写在 bioflow_progress.stages, 4 个点永远空态。 修复: - chat.js 生信消息派生 pipeline 块(derived:true):bioflowDotStepsFromStages 按「每 4 阶段一轮」把 stages 镜像为 4 相位 steps(running→running、 failed→error 单调保持、done/skipped→done);newBioflowSkeletonMessage 自带 派生块,patchBioflowEvent/patchBioflowSnapshot 每次推进后同步,error 分支 收口当前 running 相位;AssistantMessage 与 _persistableBlocks 跳过派生块 (不渲染、不落库,后端 Block union 不受影响)。 - 连带修复:web_search_started 相位 retrieval→evidence(sse_relay.py 语义 属证据补全环节,原映射会在联网检索时造成「证据→检索」倒流跳变); labels.js 兜底表补两条与 BFF 对齐。 验证:r3debug 88/88(新增 bioflowDots 14 + statusBarDots 6,含真实时序 mount→流到达→相位推进→终态)、主套件 373/373、sse_relay 29/29、dist 构建。 Co-Authored-By: Claude Fable 5 --- sdk/app/services/sse_relay.py | 2 +- sdk/app/services/test_sse_relay.py | 2 + ui/src/components/chat/AssistantMessage.vue | 2 +- ui/src/components/layout/StatusBar.vue | 4 +- ui/src/i18n/labels.js | 2 + ui/src/stores/chat.js | 113 ++++++++++-- ui/tests/r3debug/bioflowDots.test.js | 181 ++++++++++++++++++++ ui/tests/r3debug/statusBarDots.test.js | 174 +++++++++++++++++++ 8 files changed, 466 insertions(+), 14 deletions(-) create mode 100644 ui/tests/r3debug/bioflowDots.test.js create mode 100644 ui/tests/r3debug/statusBarDots.test.js diff --git a/sdk/app/services/sse_relay.py b/sdk/app/services/sse_relay.py index 8dce11e..f22dba9 100644 --- a/sdk/app/services/sse_relay.py +++ b/sdk/app/services/sse_relay.py @@ -74,7 +74,7 @@ STAGE_PHASE: dict[str, str] = { "subquestion_retry_started": "retrieval", "evidence_accepted": "evidence", "evidence_review_completed": "evidence", - "web_search_started": "retrieval", + "web_search_started": "evidence", "web_search_completed": "evidence", "answer_generation_started": "generation", "completed": "done", diff --git a/sdk/app/services/test_sse_relay.py b/sdk/app/services/test_sse_relay.py index 50c01e2..dd2941f 100644 --- a/sdk/app/services/test_sse_relay.py +++ b/sdk/app/services/test_sse_relay.py @@ -59,6 +59,8 @@ def test_stage_phase_maps_all_13_known_stages(): "subquestion_retry_started": ("retrieval", "子问题重试中"), "evidence_accepted": ("evidence", "证据已采纳"), "evidence_review_completed": ("evidence", "证据审核完成"), + "web_search_started": ("evidence", "开始联网检索"), + "web_search_completed": ("evidence", "联网检索完成"), "answer_generation_started": ("generation", "开始生成答案"), "completed": ("done", "检索完成"), } diff --git a/ui/src/components/chat/AssistantMessage.vue b/ui/src/components/chat/AssistantMessage.vue index 76e72cf..e8960b5 100644 --- a/ui/src/components/chat/AssistantMessage.vue +++ b/ui/src/components/chat/AssistantMessage.vue @@ -177,7 +177,7 @@ const degradedNote = computed(() => props.message.degradedNote || null); - + { try { const m = await import('../../stores/chat.js'); chatStore.value = m.useChatStore(); - } catch { + } catch (e) { + // 失败可见:静默 null 会让底部 4 点永远空态且无从排查 + console.error('[StatusBar] chat store 挂载失败:', e); chatStore.value = null; } }); diff --git a/ui/src/i18n/labels.js b/ui/src/i18n/labels.js index 2126926..b8af252 100644 --- a/ui/src/i18n/labels.js +++ b/ui/src/i18n/labels.js @@ -48,6 +48,8 @@ export const STAGE_PHASE = { subquestion_retry_started: 'retrieval', evidence_accepted: 'evidence', evidence_review_completed: 'evidence', + web_search_started: 'evidence', + web_search_completed: 'evidence', answer_generation_started: 'generation', completed: 'done', }; diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js index 8342370..dffd6d0 100644 --- a/ui/src/stores/chat.js +++ b/ui/src/stores/chat.js @@ -113,16 +113,33 @@ export function newBioflowSkeletonMessage() { status: 'streaming', streamErr: null, interrupted: false, - blocks: [{ - type: 'bioflow_path', - status: 'running', - candidates: [], - selected: null, - autoFilled: [], - reuseArchive: null, - reuseName: null, - fallback: null, - }], + blocks: [ + { + type: 'bioflow_path', + status: 'running', + candidates: [], + selected: null, + autoFilled: [], + reuseArchive: null, + reuseName: null, + fallback: null, + }, + // 派生 pipeline 块(derived:true):为底部状态栏 4 个 agent 点提供相位数据。 + // 生信对话从不建原生 pipeline 块,StatusBar/TopBar 只读 pipeline 块——派生块 + // 由进度块 stages 镜像而来,渲染(AssistantMessage)与落库(_persistableBlocks) + // 均跳过,仅布局组件消费。 + { + type: 'pipeline', + derived: true, + status: 'running', + steps: AGENT_DOT_PHASES.map((phase) => ({ + name: PHASE_LABEL[phase], + state: 'waiting', + desc: PHASE_DESC[phase], + phase, + })), + }, + ], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -194,6 +211,69 @@ export function patchBioflowSnapshot(aMsg, snap) { blk.stages = (snap && Array.isArray(snap.stages) ? snap.stages : []) .map((s) => progressStage(s.stage, s.status, s)); blk.lastSeq = typeof snap.last_seq === 'number' ? snap.last_seq : blk.lastSeq; + syncBioflowDotBlock(aMsg); + return blk; +} + +/** 生信阶段状态 → 相位点状态(派生 pipeline 块;单调:error 与 done 不因后续阶段回退) */ +function dotStateOf(st) { + const s = st && st.status; + if (s === 'failed') return 'error'; + if (s === 'running') return 'running'; + if (s === 'done' || s === 'skipped') return 'done'; + return 'waiting'; +} + +/** + * 进度块 stages → 底部 4 个 agent 点的 steps。 + * 相位映射按「每 4 个连续阶段 = 一轮」循环(stages[r*4+i] → AGENT_DOT_PHASES[i]), + * 与 FlowProgressCard 的相位色循环同源;waiting/缺失阶段沿用上一轮该相位状态, + * 已 error 的点不因后续阶段回退。 + */ +export function bioflowDotStepsFromStages(stages) { + const arr = Array.isArray(stages) ? stages : []; + const out = AGENT_DOT_PHASES.map((phase) => ({ + name: PHASE_LABEL[phase], + state: 'waiting', + desc: PHASE_DESC[phase], + phase, + })); + for (let i = 0; i < arr.length; i += 1) { + const phaseIdx = i % AGENT_DOT_PHASES.length; + const step = out[phaseIdx]; + const st = dotStateOf(arr[i]); + if (st === 'waiting') continue; // 沿用上一轮该相位状态 + if (step.state === 'error') continue; // error 不回退 + if (st === 'running') { + step.state = 'running'; + } else if (step.state === 'waiting') { + step.state = st; + } + } + return out; +} + +/** 找到(或创建)生信消息的派生 pipeline 块并返回之 */ +export function ensureBioflowDotBlock(aMsg) { + let blk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived); + if (!blk) { + blk = { + type: 'pipeline', + derived: true, + status: 'running', + steps: bioflowDotStepsFromStages([]), + }; + aMsg.blocks.push(blk); + } + return blk; +} + +/** 用进度块 stages 重算派生 pipeline 块(实时事件与快照回放共用) */ +export function syncBioflowDotBlock(aMsg) { + if (!aMsg || !Array.isArray(aMsg.blocks)) return null; + const prog = aMsg.blocks.find((b) => b && b.type === 'bioflow_progress'); + const blk = ensureBioflowDotBlock(aMsg); + blk.steps = bioflowDotStepsFromStages(prog && Array.isArray(prog.stages) ? prog.stages : []); return blk; } @@ -226,6 +306,7 @@ export function patchBioflowEvent(aMsg, ev) { blk.autoContinue = !!data.auto_continue; blk.stages = (Array.isArray(data.stages) ? data.stages : []) .map((s) => progressStage(s.stage || s, s.status || 'waiting', s)); + syncBioflowDotBlock(aMsg); return blk; } if (type === 'snapshot') { @@ -272,6 +353,7 @@ export function patchBioflowEvent(aMsg, ev) { if (!blk.jobStatus || blk.jobStatus === 'created') { if (blk.stages.some((s) => s.status === 'running')) blk.jobStatus = 'running'; } + syncBioflowDotBlock(aMsg); return blk; } @@ -1224,6 +1306,13 @@ export const useChatStore = defineStore('chat', { } else if (type === 'error') { // 生信服务错误帧(统一信封形态):标注可重试 endTask(); + // 派生点收口:当前运行相位置 error(防点永久 running) + const dotBlk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived); + if (dotBlk) { + for (const s of dotBlk.steps || []) { + if (s.state === 'running') s.state = 'error'; + } + } const code = (data && data.code) || 'STREAM_ERROR'; const message = (data && data.message) || BF.bioflowRouteError; updateLocal(() => { @@ -2067,7 +2156,9 @@ export const useChatStore = defineStore('chat', { if (b.type === 'pipeline' || b.type === 'stream') continue; out.push({ ...b }); } - return out; + // 派生 pipeline 块(derived:true)是布局消费的瞬态镜像,不落库 + // (后端 Block union 无此类型;历史恢复不生信点,与现状一致) + return out.filter((b) => !(b.type === 'pipeline' && b.derived)); }, async _persistFinal(aMsg, sessionId, status, { raw } = {}) { diff --git a/ui/tests/r3debug/bioflowDots.test.js b/ui/tests/r3debug/bioflowDots.test.js new file mode 100644 index 0000000..5dde7e4 --- /dev/null +++ b/ui/tests/r3debug/bioflowDots.test.js @@ -0,0 +1,181 @@ +// tests/r3debug/bioflowDots.test.js —— 底部状态栏 4 个 agent 点在生信对话中不随相位点亮 +// 缺陷:StatusBar/TopBar 只读 pipeline 块 steps,生信对话(sendBioflow)从不创建 +// pipeline 块 → 4 个点永远空态。修复:生信消息派生 pipeline 块(derived:true), +// 由进度块 stages 按「每 4 阶段一轮」镜像;渲染与落库均跳过派生块。 +import { describe, it, expect } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { + useChatStore, + newBioflowSkeletonMessage, + patchBioflowEvent, + patchBioflowSnapshot, + bioflowDotStepsFromStages, + ensureBioflowDotBlock, + syncBioflowDotBlock, +} from '../../src/stores/chat.js'; + +const PHASES = ['planning', 'retrieval', 'evidence', 'generation']; + +function dotStates(steps) { + return PHASES.map((p) => (steps.find((s) => s.phase === p) || {}).state); +} + +describe('bioflowDotStepsFromStages 纯映射', () => { + it('空 stages → 四相位全 waiting', () => { + const steps = bioflowDotStepsFromStages([]); + expect(steps.map((s) => s.phase)).toEqual(PHASES); + expect(dotStates(steps)).toEqual(['waiting', 'waiting', 'waiting', 'waiting']); + }); + + it('第一轮阶段映射:running 点亮、done 收尾、waiting 保持', () => { + const steps = bioflowDotStepsFromStages([ + { stage: 'a', status: 'running' }, + { stage: 'b', status: 'done' }, + { stage: 'c', status: 'waiting' }, + { stage: 'd', status: 'waiting' }, + ]); + expect(dotStates(steps)).toEqual(['running', 'done', 'waiting', 'waiting']); + }); + + it('轮次回绕:第二轮 running 覆盖首轮 done;未触及相位沿用首轮 done', () => { + const steps = bioflowDotStepsFromStages([ + { stage: 'a', status: 'done' }, + { stage: 'b', status: 'done' }, + { stage: 'c', status: 'done' }, + { stage: 'd', status: 'done' }, + { stage: 'e', status: 'running' }, + ]); + // e = 第 5 阶段 → 相位 planning(running 覆盖 done);retrieval 沿用首轮 done + expect(dotStates(steps)).toEqual(['running', 'done', 'done', 'done']); + }); + + it('failed → error,后续轮次不覆盖(error 单调保持)', () => { + const steps = bioflowDotStepsFromStages([ + { stage: 'a', status: 'failed' }, + { stage: 'b', status: 'waiting' }, + { stage: 'c', status: 'waiting' }, + { stage: 'd', status: 'waiting' }, + { stage: 'e', status: 'done' }, // 第二轮又轮到 planning,error 不得回退为 done + ]); + expect(dotStates(steps)).toEqual(['error', 'waiting', 'waiting', 'waiting']); + }); + + it('skipped → done(终态语义)', () => { + const steps = bioflowDotStepsFromStages([{ stage: 'a', status: 'skipped' }]); + expect(dotStates(steps)).toEqual(['done', 'waiting', 'waiting', 'waiting']); + }); + + it('阶段不足 4 个时其余相位保持 waiting', () => { + const steps = bioflowDotStepsFromStages([{ stage: 'a', status: 'done' }]); + expect(dotStates(steps)).toEqual(['done', 'waiting', 'waiting', 'waiting']); + }); +}); + +describe('派生 pipeline 块同步', () => { + it('newBioflowSkeletonMessage 自带 derived pipeline 块(全 waiting)', () => { + const msg = newBioflowSkeletonMessage(); + const dot = msg.blocks.find((b) => b.type === 'pipeline' && b.derived); + expect(dot).toBeTruthy(); + expect(dotStates(dot.steps)).toEqual(['waiting', 'waiting', 'waiting', 'waiting']); + }); + + it('patchBioflowEvent(job_created) 建进度块并同步派生块', () => { + const aMsg = { blocks: [] }; + patchBioflowEvent(aMsg, { + type: 'job_created', + data: { + job_id: 'j1', + stages: [ + { stage: 'prepare', status: 'running' }, + { stage: 'identify', status: 'waiting' }, + ], + }, + }); + const prog = aMsg.blocks.find((b) => b.type === 'bioflow_progress'); + const dot = aMsg.blocks.find((b) => b.type === 'pipeline' && b.derived); + expect(prog.jobId).toBe('j1'); + expect(dot).toBeTruthy(); + expect(dotStates(dot.steps)).toEqual(['running', 'waiting', 'waiting', 'waiting']); + }); + + it('patchBioflowEvent(stage_done) 推进后派生块随动', () => { + const aMsg = { blocks: [] }; + patchBioflowEvent(aMsg, { + type: 'job_created', + data: { job_id: 'j1', stages: [{ stage: 'prepare', status: 'running' }] }, + }); + patchBioflowEvent(aMsg, { type: 'stage_done', data: { job_id: 'j1', stage: 'prepare' } }); + const dot = aMsg.blocks.find((b) => b.type === 'pipeline' && b.derived); + expect(dotStates(dot.steps)).toEqual(['done', 'waiting', 'waiting', 'waiting']); + }); + + it('patchBioflowEvent(stage_failed) 派生块 error', () => { + const aMsg = { blocks: [] }; + patchBioflowEvent(aMsg, { + type: 'job_created', + data: { job_id: 'j1', stages: [{ stage: 'prepare', status: 'running' }] }, + }); + patchBioflowEvent(aMsg, { type: 'stage_failed', data: { job_id: 'j1', stage: 'prepare', reason: 'boom' } }); + const dot = aMsg.blocks.find((b) => b.type === 'pipeline' && b.derived); + expect(dotStates(dot.steps)).toEqual(['error', 'waiting', 'waiting', 'waiting']); + }); + + it('patchBioflowSnapshot 整体替换后派生块重算(断线重连回放路径)', () => { + const aMsg = { blocks: [] }; + patchBioflowSnapshot(aMsg, { + job_id: 'j1', + job: { status: 'running' }, + stages: [ + { stage: 'a', status: 'done' }, + { stage: 'b', status: 'done' }, + { stage: 'c', status: 'done' }, + { stage: 'd', status: 'done' }, + { stage: 'e', status: 'running' }, + ], + last_seq: 7, + }); + const dot = aMsg.blocks.find((b) => b.type === 'pipeline' && b.derived); + expect(dotStates(dot.steps)).toEqual(['running', 'done', 'done', 'done']); + }); + + it('ensureBioflowDotBlock 幂等:已存在则复用不新建', () => { + const aMsg = { blocks: [] }; + const first = ensureBioflowDotBlock(aMsg); + const second = ensureBioflowDotBlock(aMsg); + expect(second).toBe(first); + expect(aMsg.blocks.filter((b) => b.type === 'pipeline').length).toBe(1); + }); + + it('syncBioflowDotBlock 无进度块时保持全 waiting', () => { + const aMsg = { blocks: [] }; + syncBioflowDotBlock(aMsg); + const dot = aMsg.blocks.find((b) => b.type === 'pipeline' && b.derived); + expect(dotStates(dot.steps)).toEqual(['waiting', 'waiting', 'waiting', 'waiting']); + }); +}); + +describe('落库隔离', () => { + it('_persistableBlocks 保留原生 pipeline 块、排除 derived 派生块', () => { + setActivePinia(createPinia()); + const chat = useChatStore(); + const aMsg = { + blocks: [ + { + type: 'pipeline', + status: 'running', + steps: [ + { name: '规划', state: 'running', desc: '', phase: 'planning' }, + { name: '检索', state: 'waiting', desc: '', phase: 'retrieval' }, + ], + }, + { type: 'bioflow_path', status: 'done', candidates: [] }, + { type: 'pipeline', derived: true, status: 'running', steps: [] }, + ], + }; + const out = chat._persistableBlocks(aMsg); + const pipelines = out.filter((b) => b.type === 'pipeline'); + expect(pipelines.length).toBe(1); + expect(pipelines[0].derived).toBeUndefined(); + expect(out.some((b) => b.type === 'bioflow_path')).toBe(true); + }); +}); diff --git a/ui/tests/r3debug/statusBarDots.test.js b/ui/tests/r3debug/statusBarDots.test.js new file mode 100644 index 0000000..7b5f864 --- /dev/null +++ b/ui/tests/r3debug/statusBarDots.test.js @@ -0,0 +1,174 @@ +// tests/r3debug/statusBarDots.test.js —— 底部状态栏 4 个 agent 点端到端渲染 +// 缺陷:主界面底部状态栏左 4 个点在对话过程中不随相位点亮。 +// 修复:StatusBar 读取 chat.currentMessage 的 pipeline 块;生信对话无原生 pipeline +// 块,由 chat.js 派生 derived pipeline 块(由 bioflow_progress.stages 镜像)。 +// 本测试真实挂载 StatusBar(health store mock),在 pinia 写入 chat 流上下文, +// 断言 4 个点的 class 随数据点亮。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; + +vi.mock('../../src/stores/health.js', () => ({ + useHealthStore: () => ({ + qaVersion: '1.1.73-test', + sessionCount: 3, + unknown: false, + qaOk: true, + neo4jOk: true, + extractionOk: true, + }), +})); + +import StatusBar from '../../src/components/layout/StatusBar.vue'; +import { useChatStore } from '../../src/stores/chat.js'; + +const PHASES = ['planning', 'retrieval', 'evidence', 'generation']; + +function step(phase, state) { + return { name: phase, state, desc: '', phase }; +} + +function dotClasses(wrapper) { + return wrapper.findAll('.agent-dot-item .ad') + .map((d) => d.classes().filter((c) => c !== 'ad').join(',')); +} + +function setCurrentMsg(chat, aMsg) { + chat.sessionId = 's-dots'; + chat.messages = [aMsg]; + chat.sessionMsgs['s-dots'] = [aMsg]; + chat.streamCtx['s-dots'] = { + sessionId: 's-dots', + msgList: [aMsg], + controller: null, + completedOnce: false, + events: [], + aMsg, + checkpointTimer: null, + lastCheckpointAt: 0, + reveal: null, + abortAnn: null, + }; +} + +describe('StatusBar 4 个 agent 点', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('QA 流式:planning running → 点1 亮 running,其余空', async () => { + const chat = useChatStore(); + const aMsg = { + id: 'm1', role: 'assistant', kind: 'qa', status: 'streaming', + blocks: [{ + type: 'pipeline', status: 'running', + steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')), + }], + }; + setCurrentMsg(chat, aMsg); + const wrapper = mount(StatusBar); + await new Promise((r) => setTimeout(r, 30)); // onMounted 动态 import + await wrapper.vm.$nextTick(); + expect(dotClasses(wrapper)).toEqual(['running', '', '', '']); + }); + + it('QA 流式:generation running(evidence 已 done)→ 点1/3 done、点4 running', async () => { + const chat = useChatStore(); + const aMsg = { + id: 'm2', role: 'assistant', kind: 'qa', status: 'streaming', + blocks: [{ + type: 'pipeline', status: 'running', + steps: PHASES.map((p) => step(p, p === 'generation' ? 'running' : (p === 'evidence' || p === 'planning' ? 'done' : 'waiting'))), + }], + }; + setCurrentMsg(chat, aMsg); + const wrapper = mount(StatusBar); + await new Promise((r) => setTimeout(r, 30)); + await wrapper.vm.$nextTick(); + expect(dotClasses(wrapper)).toEqual(['done', '', 'done', 'running']); + }); + + it('生信对话:bioflow_progress + 派生 pipeline 块 → 点随镜像点亮(本缺陷修复)', async () => { + const chat = useChatStore(); + // 模拟 sendBioflow 消息:path 块 + 进度块(stages 3 阶段:第1 running) + 派生块 + const aMsg = { + id: 'm3', role: 'assistant', kind: 'bioflow', status: 'streaming', + blocks: [ + { type: 'bioflow_path', status: 'done', candidates: [{ stages: ['a', 'b', 'c'] }], selected: 0 }, + { + type: 'bioflow_progress', jobId: 'j1', jobStatus: 'running', + stages: [ + { stage: 'a', status: 'running' }, + { stage: 'b', status: 'waiting' }, + { stage: 'c', status: 'waiting' }, + ], + }, + { + type: 'pipeline', derived: true, status: 'running', + steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')), + }, + ], + }; + setCurrentMsg(chat, aMsg); + const wrapper = mount(StatusBar); + await new Promise((r) => setTimeout(r, 30)); + await wrapper.vm.$nextTick(); + expect(dotClasses(wrapper)).toEqual(['running', '', '', '']); + }); + + it('生信对话:历史消息无派生块(落库过滤)→ 点空态不报错', async () => { + const chat = useChatStore(); + const aMsg = { + id: 'm4', role: 'assistant', kind: 'bioflow', status: 'done', + blocks: [{ type: 'bioflow_path', status: 'done', candidates: [] }], + }; + setCurrentMsg(chat, aMsg); + const wrapper = mount(StatusBar); + await new Promise((r) => setTimeout(r, 30)); + await wrapper.vm.$nextTick(); + expect(dotClasses(wrapper)).toEqual(['', '', '', '']); + }); + + it('无当前消息(空闲)→ 4 点全空态', async () => { + const chat = useChatStore(); + chat.sessionId = 's-dots'; + chat.streamCtx['s-dots'] = null; + const wrapper = mount(StatusBar); + await new Promise((r) => setTimeout(r, 30)); + await wrapper.vm.$nextTick(); + expect(dotClasses(wrapper)).toEqual(['', '', '', '']); + }); + + it('真实时序:先 mount(挂载完成)后流到达 → computed 响应式点亮', async () => { + const chat = useChatStore(); + // 1. 先 mount,onMounted 动态 import 完成(与真实 app 启动时序一致) + const wrapper = mount(StatusBar); + await new Promise((r) => setTimeout(r, 30)); + await wrapper.vm.$nextTick(); + expect(dotClasses(wrapper)).toEqual(['', '', '', '']); + // 2. 流在 mount 之后才到达(用户发送消息) + const aMsg = { + id: 'm5', role: 'assistant', kind: 'qa', status: 'streaming', + blocks: [{ + type: 'pipeline', status: 'running', + steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')), + }], + }; + setCurrentMsg(chat, aMsg); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + expect(dotClasses(wrapper)).toEqual(['running', '', '', '']); + // 3. 相位推进:经 store 响应式代理调真实 action(_advancePipeline), + // 与真实 SSE 链同路径(直接改 raw 对象不会触发渲染——chat.js 响应式断链陷阱) + const proxyMsg = chat.streamCtx['s-dots'].aMsg; + chat._advancePipeline(proxyMsg, 'retrieval', '多路检索'); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + expect(dotClasses(wrapper)).toEqual(['done', 'running', '', '']); + // 4. 终态:done 相位全置 done + chat._advancePipeline(proxyMsg, 'done', '检索完成'); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + expect(dotClasses(wrapper)).toEqual(['done', 'done', 'done', 'done']); + }); +});