Files
deerflow-code/frontend-web/src/report-collaboration/tests/event-reducer.test.ts
T
董宏凯 438772f0b7 feat(frontend): 上线报告协作与多轮 RAG 消息聚合体验
功能说明:\n- 新增报告协作页(Report Collaboration)核心状态层、消息适配与会话面板,支持按节点/阶段选择和协作上下文展示\n- 补齐 report-collaboration API 与类型定义,并接入页面路由/构建路径,形成可运行的协作链路\n- 重构多轮 RAG 与 write/artefact 消息分组逻辑,优化 AgentChat 与 DeepResearch 场景下的消息展示顺序与可读性\n- 新增/更新 DeepResearch 前端页面(Workbench、报告配置、沙箱联动)以承接报告生成与复用能力\n- 增加报表协作相关单测,覆盖事件归并、消息适配与关键 reducer 行为
2026-09-04 23:18:27 +08:00

376 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import type { CollaborationEvent, SessionSnapshot } from "../api/types";
import {
applyCollaborationEvents,
applySessionSnapshot,
createInitialCollaborationState,
hasSeqGap,
} from "../state/event-reducer";
const SESSION = "session_test";
const RUN = "run_test";
function ev(seq: number, type: CollaborationEvent["type"], data: unknown): CollaborationEvent {
return {
eventId: `evt_${seq}`,
sessionId: SESSION,
runId: RUN,
seq,
type,
timestamp: "2026-09-02T10:00:00Z",
data,
};
}
function snapshot(overrides: Partial<SessionSnapshot> = {}): SessionSnapshot {
return {
session: {
id: SESSION,
title: "测试会话",
status: "empty",
requirement_revision: 1,
selected_plan_id: null,
active_run_id: RUN,
current_report_version_id: null,
created_at: "2026-09-02T10:00:00Z",
updated_at: "2026-09-02T10:00:00Z",
},
messages: [],
plans: [],
run: {
id: RUN,
session_id: SESSION,
plan_id: "",
status: "running",
cancel_requested: false,
next_event_seq: 1,
created_at: "2026-09-02T10:00:00Z",
updated_at: "2026-09-02T10:00:00Z",
},
nodes: [],
agent_runs: [],
commands: [],
report_versions: [],
last_seq: 0,
...overrides,
};
}
test("重复事件按 seq 去重,不产生重复消息", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
const created = ev(1, "message.created", {
message: {
message_id: "m1",
role: "ai",
content: "",
agent_run_id: "a1",
visibility: "visible",
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
});
state = applyCollaborationEvents(state, [created, created]);
assert.equal(state.messageOrder.length, 1);
assert.equal(state.messagesById.m1.content, "");
});
test("message.delta 多次更新同一 messageId,completed 收尾", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m1",
role: "ai",
content: "",
agent_run_id: "a1",
visibility: "visible",
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
}),
ev(2, "message.delta", { message_id: "m1", agent_run_id: "a1", delta: "你" }),
ev(3, "message.delta", { message_id: "m1", agent_run_id: "a1", delta: "好" }),
ev(4, "message.completed", { message_id: "m1" }),
]);
assert.equal(state.messagesById.m1.content, "你好");
assert.ok(state.messagesById.m1.completed_at);
assert.equal(state.lastSeq, 4);
});
test("message.delta 的 reasoning_delta 累加到 metadata.reasoning_content", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m_think",
role: "ai",
content: "",
agent_run_id: "coord",
visibility: "visible",
metadata: { reasoning_content: "先" },
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
}),
ev(2, "message.delta", {
message_id: "m_think",
agent_run_id: "coord",
delta: "",
reasoning_delta: "拆需求",
}),
]);
assert.equal(state.messagesById.m_think.content, "");
assert.equal(state.messagesById.m_think.metadata?.reasoning_content, "先拆需求");
});
test("乱序事件先缓冲,缺口补齐后按序 drain", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
// seq=2 先到 → 缓冲并出现缺口
state = applyCollaborationEvents(state, [
ev(2, "message.delta", { message_id: "m1", delta: "B" }),
]);
assert.equal(state.messagesById.m1, undefined);
assert.equal(hasSeqGap(state), true);
// seq=1 到达 → 1、2 依序应用
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m1",
role: "ai",
content: "A",
visibility: "visible",
created_at: "2026-09-02T10:00:01Z",
completed_at: null,
},
}),
]);
assert.equal(state.messagesById.m1.content, "AB");
assert.equal(hasSeqGap(state), false);
});
test("工具调用挂在所属 ai 消息,返回以 tool 消息配对 tool_call_id", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "tool_call.created", {
message_id: "ai_owner",
agent_run_id: "a1",
tool_call: { id: "tc1", name: "web_search", args: "" },
}),
ev(2, "tool_call.delta", { message_id: "ai_owner", tool_call_id: "tc1", args_delta: '{"q":"' }),
ev(3, "tool_call.delta", { message_id: "ai_owner", tool_call_id: "tc1", args_delta: '市场"}' }),
ev(4, "tool_result.created", {
message_id: "tr1",
tool_call_id: "tc1",
name: "web_search",
agent_run_id: "a1",
content: '{"count":8}',
}),
]);
const owner = state.messagesById.ai_owner;
assert.equal(owner.role, "ai");
assert.equal(owner.tool_calls?.[0].id, "tc1");
assert.equal(owner.tool_calls?.[0].args, '{"q":"市场"}');
const toolMessage = state.messagesById.tr1;
assert.equal(toolMessage.role, "tool");
assert.equal(toolMessage.tool_call_id, "tc1");
assert.equal(state.messageOrder[0], "ai_owner");
assert.equal(state.messageOrder[1], "tr1");
assert.ok(owner.completed_at, "工具-only 的 ai 行在 tool 返回后应收尾,避免步骤条一直转圈");
});
test("节点状态机守卫:cancelled 后晚到 completed 被忽略", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "node.created", {
node: {
node_id: "n1",
label: "研究",
status: "planned",
attempt: 0,
attempts: [],
},
}),
ev(2, "node.status.changed", { node_id: "n1", status: "running", attempt: 1 }),
// §5.5:running 经 cancel_requested 再到 cancelled
ev(3, "node.status.changed", { node_id: "n1", status: "cancel_requested", attempt: 1 }),
ev(4, "node.status.changed", { node_id: "n1", status: "cancelled", attempt: 1 }),
// 取消后的晚到输出不得改写终态
ev(5, "node.status.changed", { node_id: "n1", status: "validating", attempt: 1 }),
ev(6, "node.status.changed", { node_id: "n1", status: "completed", attempt: 1 }),
]);
assert.equal(state.nodes.n1.status, "cancelled");
// completed -> reopened -> queued 合法
state = applyCollaborationEvents(state, [
ev(7, "node.status.changed", { node_id: "n2", status: "completed", attempt: 1 }),
ev(8, "node.status.changed", { node_id: "n2", status: "reopened", attempt: 1 }),
ev(9, "node.status.changed", { node_id: "n2", status: "queued", attempt: 2 }),
]);
assert.equal(state.nodes.n2.status, "queued");
});
test("协助卡出现(awaiting_input)不等于完成;回到 queued 由状态事件驱动", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "node.created", {
node: { node_id: "n1", label: "研究", status: "running", attempt: 1, attempts: [] },
}),
ev(2, "node.status.changed", {
node_id: "n1",
status: "awaiting_input",
attempt: 1,
awaiting_input: { reason: "需要补充范围", question: "覆盖哪些品牌?" },
}),
]);
assert.equal(state.nodes.n1.status, "awaiting_input");
assert.equal(state.nodes.n1.awaiting_input?.question, "覆盖哪些品牌?");
state = applyCollaborationEvents(state, [
ev(3, "node.status.changed", { node_id: "n1", status: "queued", attempt: 1 }),
]);
assert.equal(state.nodes.n1.status, "queued");
assert.equal(state.nodes.n1.awaiting_input, null);
});
test("并行成员事件按 agentRunId 各自更新,互不影响", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "m_market",
role: "ai",
content: "",
agent_run_id: "a_market",
visibility: "visible",
created_at: "t",
completed_at: null,
},
}),
ev(2, "message.created", {
message: {
message_id: "m_brand",
role: "ai",
content: "",
agent_run_id: "a_brand",
visibility: "visible",
created_at: "t",
completed_at: null,
},
}),
ev(3, "message.delta", { message_id: "m_market", agent_run_id: "a_market", delta: "市场" }),
ev(4, "message.delta", { message_id: "m_brand", agent_run_id: "a_brand", delta: "品牌" }),
ev(5, "message.completed", { message_id: "m_market" }),
]);
assert.equal(state.messagesById.m_market.content, "市场");
assert.equal(state.messagesById.m_brand.content, "品牌");
assert.ok(state.messagesById.m_market.completed_at);
assert.equal(state.messagesById.m_brand.completed_at, null);
});
test("乐观用户消息由同 idempotency_key 的服务器镜像原地替换", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "message.created", {
message: {
message_id: "pending:key-1",
role: "human",
content: "写报告",
visibility: "visible",
metadata: { optimistic: true, idempotency_key: "key-1" },
created_at: "t",
completed_at: "t",
},
}),
ev(2, "message.created", {
message: {
message_id: "srv-1",
role: "human",
content: "写报告",
visibility: "visible",
metadata: { idempotency_key: "key-1" },
created_at: "t",
completed_at: "t",
},
}),
]);
assert.equal(state.messageOrder.length, 1);
assert.equal(state.messageOrder[0], "srv-1");
assert.equal(state.messagesById["pending:key-1"], undefined);
});
test("其他 session / 旧 run 的晚到事件被丢弃", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
const foreignSession = { ...ev(1, "message.created", { message: { message_id: "x" } }) };
foreignSession.sessionId = "session_other";
const foreignRun = ev(2, "message.created", {
message: {
message_id: "y",
role: "ai",
content: "",
visibility: "visible",
created_at: "t",
completed_at: null,
},
});
foreignRun.runId = "run_old";
state = applyCollaborationEvents(state, [foreignSession, foreignRun]);
assert.equal(state.messageOrder.length, 0);
assert.equal(state.lastSeq, 0);
});
test("plan.proposed 替换方案组;plan.selected 记录选定且不改执行", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
const plan = {
id: "plan-1",
proposal_group_id: "g1",
title: "方案一",
strategy: "balanced_review",
summary: "",
rationale: "",
estimated_duration_seconds: 60,
estimated_cost_level: "medium" as const,
requirement_revision: 1,
roles: [],
nodes: [],
edges: [],
quality_gates: [],
report_structure_id: null,
validation: { ok: true, errors: [] },
revision: 1,
status: "proposed" as const,
};
state = applyCollaborationEvents(state, [ev(1, "plan.proposed", { plans: [plan] })]);
assert.equal(state.plans.length, 1);
assert.equal(state.selectedPlanId, null);
state = applyCollaborationEvents(state, [ev(2, "plan.selected", { plan_id: "plan-1" })]);
assert.equal(state.selectedPlanId, "plan-1");
assert.equal(state.plans[0].status, "selected");
assert.equal(state.run?.status, "running"); // 选择不触发执行
});
test("report.delta 累积草稿,version.created 后草稿清空", () => {
let state = applySessionSnapshot(createInitialCollaborationState(SESSION), snapshot());
state = applyCollaborationEvents(state, [
ev(1, "report.delta", { draft_id: "d1", delta: "# 报" }),
ev(2, "report.delta", { draft_id: "d1", delta: "告" }),
]);
assert.equal(state.reportDraft?.markdown, "# 报告");
state = applyCollaborationEvents(state, [
ev(3, "report.version.created", {
version: {
id: "v1",
session_id: SESSION,
version: 1,
parent_version_id: null,
change_type: "initial",
markdown: "# 报告",
created_at: "t",
is_head: true,
},
}),
]);
assert.equal(state.reportDraft, null);
assert.equal(state.reportVersions.length, 1);
assert.equal(state.session?.current_report_version_id, "v1");
});