This commit is contained in:
2026-07-30 08:42:55 +08:00
parent 48b7391365
commit 1867649527
19 changed files with 1877 additions and 486 deletions

View File

@ -152,6 +152,7 @@ let nodes = definitions.map(([key, title, description]) => ({
let skillName = "未命名 Skill";
let skillDescription = "";
let builderMessages = [];
let pendingProposal = null;
async function builderTurn(message) {
const clientRequestId = `acceptance_builder_${crypto.randomUUID()}`;
@ -163,6 +164,7 @@ async function builderTurn(message) {
message,
skillName,
skillDescription,
pendingProposal,
nodes,
messages: builderMessages,
});
@ -216,6 +218,7 @@ async function builderTurn(message) {
nodes = update.evaluation.nodes;
skillName = update.evaluation.skillName;
skillDescription = update.evaluation.skillDescription;
pendingProposal = update.evaluation.proposal;
const reply = events
.filter((event) => event.type === "token")
.map((event) => event.token)
@ -231,6 +234,21 @@ async function runAcceptance() {
await waitForServer();
console.log("✓ 测试服务与独立 SQLite 已启动");
const initialPageResponse = await fetch(baseUrl);
const initialPageHtml = await initialPageResponse.text();
assert.equal(initialPageResponse.ok, true, "工作台首屏必须可以直接访问");
assert.doesNotMatch(
initialPageHtml,
/正在打开工作台/,
"首屏不得依赖客户端 effect 才能离开加载状态",
);
assert.match(
initialPageHtml,
/有什么我可以帮你/,
"服务端首屏必须直接输出可用的工作台内容",
);
console.log("✓ 工作台首屏由服务端数据直接渲染,不会卡在加载状态");
const migratedChat = await jsonRequest("/api/chat");
assert.equal(migratedChat.continuation.state, "awaiting_input");
assert.deepEqual(migratedChat.continuation.skillIds, ["skill_sql_guard"]);
@ -433,12 +451,37 @@ async function runAcceptance() {
console.log("✓ Builder 拒绝脚本/API 执行能力并给出可行替代");
const incompleteTurn = await builderTurn("做一套清单");
assert.equal(
incompleteTurn.evaluation.action,
"proposed",
"规范内容必须先作为待确认提案返回",
);
assert.equal(
nodes.every((node) => node.content === ""),
true,
"用户确认前不得写入任何节点内容",
);
assert.match(
incompleteTurn.reply,
/待确认|确认写入/,
"聊天回复必须明确展示待确认状态",
);
const confirmedIncompleteTurn = await builderTurn("确认写入");
assert.equal(
confirmedIncompleteTurn.evaluation.action,
"applied",
"用户确认后才允许写入提案",
);
assert.equal(
nodes.filter((node) => node.completed).length,
0,
"信息不足时节点不能提前完成",
);
assert.match(incompleteTurn.reply, /[?]/, "信息不足时 AI 必须继续追问");
assert.match(
confirmedIncompleteTurn.reply,
/[?]/,
"确认写入的信息仍不足时 AI 必须继续追问",
);
const multiNodeTurn = await builderTurn(
[
@ -449,9 +492,46 @@ async function runAcceptance() {
].join(""),
);
assert.deepEqual(
multiNodeTurn.evaluation.updatedNodeKeys,
multiNodeTurn.evaluation.proposedNodeKeys,
["trigger", "inputs", "steps", "constraints"],
"一条连贯消息涵盖多个节点时AI 必须一次归类全部内容",
"一条连贯消息涵盖多个节点时AI 必须一次归类全部提案内容",
);
assert.deepEqual(
multiNodeTurn.evaluation.updatedNodeKeys,
[],
"多节点提案确认前不得产生实际更新",
);
assert.equal(
nodes.filter((node) => node.completed).length,
0,
"多节点提案确认前进度不得变化",
);
const revisedMultiNodeTurn = await builderTurn(
"输入参数改为:必填产品需求、目标用户和验收环境,可选优先级与发布日期。",
);
assert.equal(
revisedMultiNodeTurn.evaluation.action,
"proposed",
"修改意见必须生成新版提案,而不是直接写入",
);
assert.match(
pendingProposal.updates.find((update) => update.nodeKey === "inputs")
?.content ?? "",
/验收环境/,
"新版提案必须吸收用户的修改意见",
);
assert.equal(
nodes.filter((node) => node.completed).length,
0,
"修改待确认提案时节点仍不得变化",
);
const confirmedMultiNodeTurn = await builderTurn("确认写入");
assert.deepEqual(
confirmedMultiNodeTurn.evaluation.updatedNodeKeys,
["trigger", "inputs", "steps", "constraints"],
"确认后必须一次写入完整的多节点提案",
);
assert.equal(
nodes.filter((node) => node.completed).length,
@ -473,9 +553,20 @@ async function runAcceptance() {
"输出格式:输出 Markdown 表格,字段为编号、前置条件、操作、预期结果。",
);
assert.deepEqual(
filledGap.evaluation.updatedNodeKeys,
filledGap.evaluation.proposedNodeKeys,
["output"],
"补充缺口时 AI 应更新第 4 节点",
"补充缺口时 AI 应先提议更新第 4 节点",
);
assert.equal(
nodes.filter((node) => node.completed).length,
3,
"补充缺口的提案确认前不得改变进度",
);
const confirmedGap = await builderTurn("确认写入");
assert.deepEqual(
confirmedGap.evaluation.updatedNodeKeys,
["output"],
"确认后才应更新第 4 节点",
);
assert.equal(
nodes.filter((node) => node.completed).length,
@ -483,11 +574,13 @@ async function runAcceptance() {
"第 4 节点补齐后,已准备好的第 5 节点必须自动完成",
);
assert.doesNotMatch(
filledGap.reply,
confirmedGap.reply,
/约束与测试[^。]*[?]/,
"全部节点自动完成后不得继续追问已经解锁的节点",
);
console.log("✓ 一轮可完成多个连续节点,缺口补齐后自动解锁后续节点");
console.log(
"✓ 节点内容先展示提案,支持修改,确认后再写入并按顺序解锁",
);
assert.deepEqual(
getSkillNodeQualityIssues(nodes),
@ -594,8 +687,24 @@ async function runAcceptance() {
"补充规则:当需求只有一句话时,先追问目标用户再生成清单。",
);
assert.ok(
editedTurn.evaluation.updatedNodeKeys.includes("trigger"),
"编辑时也必须由 AI 自动判断目标节点",
editedTurn.evaluation.proposedNodeKeys.includes("trigger"),
"编辑时也必须由 AI 自动判断待修改的目标节点",
);
const nodesBeforeDiscard = structuredClone(nodes);
const discardedTurn = await builderTurn("放弃提案");
assert.equal(discardedTurn.evaluation.action, "discarded");
assert.deepEqual(nodes, nodesBeforeDiscard, "放弃提案不得改变现有节点");
const confirmedEditProposal = await builderTurn(
"补充规则:当需求只有一句话时,先追问目标用户再生成清单。",
);
assert.ok(
confirmedEditProposal.evaluation.proposedNodeKeys.includes("trigger"),
);
const confirmedEdit = await builderTurn("确认写入");
assert.ok(
confirmedEdit.evaluation.updatedNodeKeys.includes("trigger"),
"编辑提案也必须在确认后才写入目标节点",
);
await jsonRequest(`/api/skills/${skillId}`, {
method: "PUT",
@ -828,7 +937,11 @@ async function runAcceptance() {
const sqlReviewSkill = seededSkills.skills.find(
(skill) => skill.name === "SQL 安全审阅",
);
const decisionNoteSkill = seededSkills.skills.find(
(skill) => skill.name === "决策备忘录",
);
assert.ok(sqlReviewSkill, "预置的 SQL 安全审阅 Skill 必须存在");
assert.ok(decisionNoteSkill, "预置的决策备忘录 Skill 必须存在");
const explainSkillPayload = await jsonRequest("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -866,6 +979,79 @@ async function runAcceptance() {
);
console.log("✓ 询问 Skill 用途时严格返回已选 Skill 的真实定义");
const stagedSkillPayload = await jsonRequest("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversationId: chat.conversation.id,
message: "请分别介绍这些 Skill 的用途",
selectedSkillIds: [decisionNoteSkill.id, sqlReviewSkill.id],
clientRequestId: `acceptance_staged_skills_${crypto.randomUUID()}`,
}),
});
const stagedSkillEvents = await readSse(
await fetch(
`${baseUrl}/api/chat/runs/${stagedSkillPayload.run.id}/stream?after=0`,
),
);
assert.deepEqual(
stagedSkillEvents
.filter(
(event) =>
event.type === "skill_started" ||
event.type === "skill_completed",
)
.map((event) => [event.type, event.skillId]),
[
["skill_started", decisionNoteSkill.id],
["skill_completed", decisionNoteSkill.id],
["skill_started", sqlReviewSkill.id],
["skill_completed", sqlReviewSkill.id],
],
"多个 Skill 必须按用户选择顺序逐个发送开始与完成事件",
);
const stagedStartedEvents = stagedSkillEvents.filter(
(event) => event.type === "skill_started",
);
const stagedCompletedEvents = stagedSkillEvents.filter(
(event) => event.type === "skill_completed",
);
assert.equal(
stagedStartedEvents[0]?.input?.request,
"请分别介绍这些 Skill 的用途",
"Skill 开始事件必须包含实际用户请求",
);
assert.match(
stagedCompletedEvents[0]?.result ?? "",
/复杂选项整理成有依据、可复盘的决策建议/,
"Skill 完成事件必须包含真实阶段产物",
);
assert.doesNotMatch(
stagedCompletedEvents[0]?.result ?? "",
/\"used\"\s*:\s*true/,
"Skill 结果不得再使用 used=true 占位",
);
const chatWithStagedExecutions = await jsonRequest("/api/chat");
const persistedStagedMessage = chatWithStagedExecutions.messages.find(
(message) => message.id === stagedSkillPayload.run.assistantMessageId,
);
assert.equal(
persistedStagedMessage?.skillExecutions.length,
2,
"助手消息必须持久化每个 Skill 的执行记录",
);
assert.ok(
persistedStagedMessage?.skillExecutions.every(
(execution) =>
execution.status === "completed" &&
execution.input.request === "请分别介绍这些 Skill 的用途" &&
typeof execution.result === "string" &&
execution.result.length > 0,
),
"刷新后仍应读取真实 Skill 输入与结果",
);
console.log("✓ 多 Skill 按顺序执行并持久化真实输入与阶段结果");
const followUpPayload = await jsonRequest("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -873,7 +1059,7 @@ async function runAcceptance() {
conversationId: chat.conversation.id,
message:
"产品需求 验收清单:我还没提供具体内容,请先询问我需要补充的信息。",
selectedSkillIds: [skillId],
selectedSkillIds: [skillId, sqlReviewSkill.id],
clientRequestId: `acceptance_follow_up_${crypto.randomUUID()}`,
}),
});
@ -885,6 +1071,20 @@ async function runAcceptance() {
const followUpRetention = followUpEvents.find(
(event) => event.type === "skill_retention",
);
const followUpUsage = followUpEvents.find(
(event) => event.type === "skill_usage",
);
assert.equal(
followUpUsage?.skillIds.length,
1,
"路由器应只使用两个已勾选 Skill 中的相关子集",
);
assert.ok(
followUpUsage.skillIds.every((id) =>
[skillId, sqlReviewSkill.id].includes(id),
),
"实际使用项不得超出用户勾选集合",
);
assert.equal(
followUpRetention?.state,
"awaiting_input",
@ -892,13 +1092,13 @@ async function runAcceptance() {
);
assert.deepEqual(
followUpRetention?.skillIds,
[skillId],
"未完成的多轮任务必须保留实际使用的 Skill",
[skillId, sqlReviewSkill.id],
"未完成的需求必须保留全部勾选 Skill不能丢弃当轮未使用项",
);
const chatWithRetainedSkill = await jsonRequest("/api/chat");
assert.deepEqual(
chatWithRetainedSkill.retainedSkillIds,
[skillId],
[skillId, sqlReviewSkill.id],
"刷新页面后仍必须恢复待继续任务的 Skill",
);
assert.equal(
@ -915,7 +1115,40 @@ async function runAcceptance() {
"deterministic",
"明确追问应由确定性规则识别,不依赖 AI 猜测",
);
console.log("✓ AI 追问时跨轮保留 Skill并持久化到 SQLite");
const followUpAssistantMessage = chatWithRetainedSkill.messages.find(
(message) => message.id === followUpPayload.run.assistantMessageId,
);
const pinnedSnapshot = followUpAssistantMessage?.skillSnapshots.find(
(snapshot) => snapshot.id === skillId,
);
assert.ok(pinnedSnapshot?.version >= 1, "消息必须保存 Skill 版本快照");
const latestSkillBeforeEdit = (
await jsonRequest(`/api/skills/${skillId}`)
).skill;
assert.equal(
pinnedSnapshot.version,
latestSkillBeforeEdit.version,
"首次进入需求时必须固定当时的 Skill 版本",
);
const updatedDuringTask = await jsonRequest(`/api/skills/${skillId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: latestSkillBeforeEdit.name,
description: `${latestSkillBeforeEdit.description}(新版)`,
nodes: latestSkillBeforeEdit.nodes,
status: "active",
}),
});
assert.equal(
updatedDuringTask.skill.version,
pinnedSnapshot.version + 1,
"每次编辑 Skill 必须递增版本号",
);
console.log(
"✓ AI 追问时保留全部勾选 Skill并固定版本快照到 SQLite",
);
const continuedPayload = await jsonRequest("/api/chat", {
method: "POST",
@ -923,7 +1156,7 @@ async function runAcceptance() {
body: JSON.stringify({
conversationId: chat.conversation.id,
message: "需要",
selectedSkillIds: [skillId],
selectedSkillIds: [skillId, sqlReviewSkill.id],
clientRequestId: `acceptance_continued_skill_${crypto.randomUUID()}`,
}),
});
@ -937,7 +1170,7 @@ async function runAcceptance() {
);
assert.deepEqual(
continuedUsage?.skillIds,
[skillId],
[skillId, sqlReviewSkill.id],
"用户用“需要”等短回复接受上一轮提议时必须继续使用保留的 Skill",
);
assert.equal(
@ -945,7 +1178,18 @@ async function runAcceptance() {
"continuation",
"省略回复必须通过显式续接状态恢复 Skill",
);
console.log("✓ “需要”等省略回复会继续使用上一轮保留的 Skill");
const chatAfterContinuation = await jsonRequest("/api/chat");
const continuedAssistantMessage = chatAfterContinuation.messages.find(
(message) => message.id === continuedPayload.run.assistantMessageId,
);
assert.equal(
continuedAssistantMessage?.skillSnapshots.find(
(snapshot) => snapshot.id === skillId,
)?.version,
pinnedSnapshot.version,
"进行中的需求必须继续使用旧快照,不能混入刚编辑的新版本",
);
console.log("✓ 省略回复续接所选 Skill并沿用需求开始时的版本快照");
const stopPayload = await jsonRequest("/api/chat", {
method: "POST",
@ -953,7 +1197,7 @@ async function runAcceptance() {
body: JSON.stringify({
conversationId: chat.conversation.id,
message: "请生成一份很长的补充清单。",
selectedSkillIds: [],
selectedSkillIds: [skillId],
clientRequestId: `acceptance_stop_${crypto.randomUUID()}`,
}),
});
@ -1001,6 +1245,23 @@ async function runAcceptance() {
stoppedMessage?.usage?.durationMs >= 0,
"中止回答也必须记录端到端用时",
);
assert.equal(
chatAfterStop.continuation.state,
"active",
"中止回答只结束 Run不能把当前需求误判为完成",
);
assert.deepEqual(
chatAfterStop.retainedSkillIds,
[skillId],
"中止后必须维持用户勾选的 Skill",
);
assert.equal(
stoppedMessage?.skillSnapshots.find(
(snapshot) => snapshot.id === skillId,
)?.version,
updatedDuringTask.skill.version,
"上一个需求完成后,新需求必须获取 Skill 的最新版本快照",
);
assert.equal(
chatAfterStop.messages.some(
(message) => message.content === "这条并发冲突消息不应写入数据库。",
@ -1008,7 +1269,7 @@ async function runAcceptance() {
false,
"并发冲突事务不得留下孤立消息",
);
console.log("✓ 中止被视为一轮完成");
console.log("✓ 中止原子落库且维持当前需求的 Skill 选择");
const renamed = await jsonRequest("/api/conversation", {
method: "PATCH",
@ -1037,7 +1298,7 @@ async function runAcceptance() {
try {
await runAcceptance();
console.log("\n验收通过创建、编辑、删除、选择、调用反馈、中止、标题与重连均正常。");
console.log("\n验收通过生命周期、版本快照、中止竞态、创建编辑与重连均正常。");
} finally {
server.kill("SIGTERM");
await new Promise((resolve) => {