功能说明:\n- 新增报告协作页(Report Collaboration)核心状态层、消息适配与会话面板,支持按节点/阶段选择和协作上下文展示\n- 补齐 report-collaboration API 与类型定义,并接入页面路由/构建路径,形成可运行的协作链路\n- 重构多轮 RAG 与 write/artefact 消息分组逻辑,优化 AgentChat 与 DeepResearch 场景下的消息展示顺序与可读性\n- 新增/更新 DeepResearch 前端页面(Workbench、报告配置、沙箱联动)以承接报告生成与复用能力\n- 增加报表协作相关单测,覆盖事件归并、消息适配与关键 reducer 行为
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
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");
|
||
});
|