生产实测暴露缺口:生信对话的 chat/stream 在 plan_ready 后即关闭 (_finishCtx 清 streamCtx→currentMessage 为 null),而任务阶段恰在 流关闭后才真正运行——StatusBar 只读 currentMessage,阶段运行期 间 4 点仍不亮。这正是用户报的 bug 主场景。 - chat.js 新增 agentDotMessage getter:优先流式中 currentMessage; 流已收口时回退到消息列表里持有活跃生信任务(progress.jobId 且 jobStatus ∈ created/running/paused)的最后一条助手消息——其派生 pipeline 块正被任务事件订阅(connectJobEvents onEvent → patchBioflowEvent → syncBioflowDotBlock)持续刷新。 - StatusBar.vue 数据源换 agentDotMessage;TopBar.vue runningPhaseOf 同换 + aggregate 在生信任务活跃期(busy=false 但有 running 相位) 点亮聚合点。 - 单测补生产实测场景:流已收口任务活跃→回退点亮;阶段推进随动; 任务终态正确收口空态。 验证:r3debug 89/89(新增回退用例)、主套件 373/373、dist 重建。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
224 lines
8.7 KiB
JavaScript
224 lines
8.7 KiB
JavaScript
// 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,
|
|
syncBioflowDotBlock,
|
|
} 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('生信流已收口但任务活跃(2026-09-12 生产实测缺口)→ 回退消息列表点亮', async () => {
|
|
const chat = useChatStore();
|
|
// 模拟真实场景:chat/stream 已在 plan_ready 后关闭,_finishCtx 清理了 ctx
|
|
// (streamCtx 无本会话键),但任务阶段仍在运行,派生块正被任务事件订阅刷新
|
|
const aMsg = {
|
|
id: 'm6', role: 'assistant', kind: 'bioflow', status: 'streaming',
|
|
blocks: [
|
|
{ type: 'bioflow_path', status: 'done', jobId: 'j2', candidates: [{ stages: ['a', 'b'] }], selected: 0 },
|
|
{
|
|
type: 'bioflow_progress', jobId: 'j2', jobStatus: 'running',
|
|
stages: [
|
|
{ stage: 'a', status: 'running' },
|
|
{ stage: 'b', status: 'waiting' },
|
|
],
|
|
},
|
|
{
|
|
type: 'pipeline', derived: true, status: 'running',
|
|
steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')),
|
|
},
|
|
],
|
|
};
|
|
chat.sessionId = 's-dots2';
|
|
chat.messages = [aMsg];
|
|
chat.sessionMsgs['s-dots2'] = [aMsg];
|
|
chat.streamCtx = {}; // 关键:无活跃流(ctx 已被 _finishCtx 清理)
|
|
expect(chat.agentDotMessage).toBe(chat.messages[0]); // getter 回退正确
|
|
const wrapper = mount(StatusBar);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
await wrapper.vm.$nextTick();
|
|
expect(dotClasses(wrapper)).toEqual(['running', '', '', '']);
|
|
// 阶段推进:事件 patch 后派生块随动(模拟任务事件订阅 onEvent 链)
|
|
const proxyMsg = chat.messages[0];
|
|
const blk = proxyMsg.blocks.find((b) => b.type === 'bioflow_progress');
|
|
blk.stages[0].status = 'done';
|
|
blk.stages[1].status = 'running';
|
|
syncBioflowDotBlock(proxyMsg);
|
|
await wrapper.vm.$nextTick();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
expect(dotClasses(wrapper)).toEqual(['done', 'running', '', '']);
|
|
// 任务终态:jobStatus done → getter 不再回退,点全空态(正确收口)
|
|
blk.jobStatus = 'done';
|
|
await wrapper.vm.$nextTick();
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
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']);
|
|
});
|
|
});
|