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']);
+ });
+});