update
This commit is contained in:
@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user