import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { mkdirSync, rmSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { classifyContinuationReply, detectConversationContinuation, isActionableSkillContinuation, isLikelySkillContinuationReply, } from "../src/lib/skill-continuation.ts"; import { findUnsupportedSkillCapabilities, getSkillCapabilityViolations, } from "../src/lib/skill-capabilities.ts"; import { isSuggestionWithinNodeScope } from "../src/lib/skill-recommendation-scope.ts"; import { evaluateSkillNodeQuality, getSkillNodeQualityIssues, } from "../src/lib/skill-node-quality.ts"; import { estimateDeepSeekCallUsage } from "../src/lib/model-usage.ts"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const port = 3310 + (process.pid % 200); const baseUrl = `http://127.0.0.1:${port}`; const tempDir = path.join(root, ".acceptance"); const databasePath = path.join(tempDir, `skillloom-${process.pid}.db`); const nextBin = path.join(root, "node_modules", "next", "dist", "bin", "next"); mkdirSync(tempDir, { recursive: true }); const legacyDatabase = new DatabaseSync(databasePath); legacyDatabase.exec(` CREATE TABLE conversations ( id TEXT PRIMARY KEY, title TEXT NOT NULL, retained_skill_ids TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); INSERT INTO conversations (id, title, retained_skill_ids, created_at, updated_at) VALUES ('legacy_conversation', '', '["skill_sql_guard"]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'); `); legacyDatabase.close(); const server = spawn(process.execPath, [nextBin, "start", "-p", String(port)], { cwd: root, env: { ...process.env, DATABASE_PATH: databasePath, DEEPSEEK_API_KEY: "", NEXT_TELEMETRY_DISABLED: "1", }, stdio: ["ignore", "pipe", "pipe"], }); let serverLog = ""; server.stdout.on("data", (chunk) => { serverLog += chunk.toString(); }); server.stderr.on("data", (chunk) => { serverLog += chunk.toString(); }); async function waitForServer() { for (let attempt = 0; attempt < 60; attempt += 1) { try { const response = await fetch(`${baseUrl}/api/chat`); if (response.ok) return; } catch { // The server has not opened its port yet. } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(`测试服务未启动:\n${serverLog}`); } async function jsonRequest(url, init) { const response = await fetch(`${baseUrl}${url}`, init); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(`${init?.method ?? "GET"} ${url}: ${JSON.stringify(payload)}`); } return payload; } async function readSse(response) { assert.equal(response.ok, true, `SSE 请求失败:${response.status}`); assert.ok(response.body, "SSE 响应没有 body"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const events = []; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() ?? ""; for (const block of blocks) { const data = block .split(/\r?\n/) .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trim()) .join("\n"); if (data) events.push(JSON.parse(data)); } } return events; } async function readFirstSseBatch(response) { assert.ok(response.body, "SSE 响应没有 body"); const reader = response.body.getReader(); const decoder = new TextDecoder(); const { value } = await reader.read(); await reader.cancel(); const text = decoder.decode(value); return text .split(/\r?\n\r?\n/) .map((block) => block .split(/\r?\n/) .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trim()) .join("\n"), ) .filter(Boolean) .map((data) => JSON.parse(data)); } const definitions = [ ["trigger", "触发条件", "什么时候应该使用这个 Skill,以及不适用的边界。"], ["inputs", "输入参数", "执行所需的信息、字段、格式和默认值。"], ["steps", "执行步骤", "AI 应严格遵循的行动顺序与关键判断。"], ["output", "输出格式", "最终交付物的结构、字段、语气和示例。"], ["constraints", "约束与测试", "禁止事项、质量门槛、失败处理和验收用例。"], ]; let nodes = definitions.map(([key, title, description]) => ({ key, title, description, content: "", ready: false, completed: false, })); let skillName = "未命名 Skill"; let skillDescription = ""; let builderMessages = []; let pendingProposal = null; async function builderTurn(message) { const clientRequestId = `acceptance_builder_${crypto.randomUUID()}`; const requestBody = (afterSeq) => JSON.stringify({ clientRequestId, afterSeq, skillId: null, message, skillName, skillDescription, pendingProposal, nodes, messages: builderMessages, }); const response = await fetch(`${baseUrl}/api/skills/builder`, { method: "POST", headers: { "Content-Type": "application/json" }, body: requestBody(0), }); let events; if (builderMessages.length === 0) { const firstEvents = await readFirstSseBatch(response); const cursor = Math.max(...firstEvents.map((event) => event.seq ?? 0)); const resumed = await fetch(`${baseUrl}/api/skills/builder`, { method: "POST", headers: { "Content-Type": "application/json" }, body: requestBody(cursor), }); events = [...firstEvents, ...(await readSse(resumed))]; const sequences = events.map((event) => event.seq).filter(Boolean); assert.equal( new Set(sequences).size, sequences.length, "Builder 重连不得重复事件", ); } else { events = await readSse(response); } const update = events.find((event) => event.type === "builder_update"); assert.ok(update, "Builder 必须返回结构化节点更新"); assert.ok( Array.isArray(update.evaluation.suggestions) && update.evaluation.suggestions.length >= 2 && update.evaluation.suggestions.length <= 4, "Builder 必须返回 2-4 条结构化 AI 智能建议", ); assert.equal( update.evaluation.suggestionNodeKey, update.evaluation.activeNode, "Builder 推荐必须明确归属于当前待完善节点", ); assert.equal( update.evaluation.nodeQuality.length, 5, "Builder 必须返回五节点质量判定,便于排查进度原因", ); assert.ok( events.some((event) => event.type === "complete"), "Builder 必须发出完成事件", ); 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) .join(""); builderMessages.push( { role: "user", content: message }, { role: "assistant", content: reply }, ); return { evaluation: update.evaluation, reply }; } 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"]); assert.equal(migratedChat.continuation.source, "fallback"); console.log("✓ 旧版 retained_skill_ids 已兼容迁移为显式续接状态"); const pricedUsage = estimateDeepSeekCallUsage({ model: "deepseek-v4-flash", promptTokens: 3_000, promptCacheHitTokens: 1_000, promptCacheMissTokens: 2_000, completionTokens: 800, }); assert.equal(pricedUsage.totalTokens, 3_800); assert.equal( pricedUsage.estimatedCostMicros, 3_620, "费用必须分别按缓存命中输入、未命中输入和输出计算", ); console.log("✓ Token 用量按模型价格快照计算预计费用"); const concreteOffer = "需要我根据你的实际可用时间调整这份学习计划吗?"; assert.equal( isActionableSkillContinuation(concreteOffer), true, "带有明确动作和对象的后续提议必须保留 Skill", ); assert.equal( detectConversationContinuation(concreteOffer)?.state, "offer_pending", "具体后续提议必须显式记录为等待确认", ); assert.equal(classifyContinuationReply("需要"), "accept"); assert.equal(classifyContinuationReply("每天 1 小时"), "answer"); assert.equal(classifyContinuationReply("不用了"), "decline"); assert.equal( isActionableSkillContinuation("如果需要,我可以继续帮助你。"), false, "泛泛的礼貌性邀请不得让 Skill 一直保留", ); assert.equal( isLikelySkillContinuationReply("需要", [ { role: "assistant", content: concreteOffer }, ]), true, "简短的接受回复必须识别为上一轮的延续", ); assert.equal( isLikelySkillContinuationReply("帮我查一下天气", [ { role: "assistant", content: concreteOffer }, ]), false, "用户切换到新任务时不得强制沿用旧 Skill", ); console.log("✓ 具体后续提议可续接,礼貌邀请与新话题不会粘住 Skill"); assert.deepEqual( findUnsupportedSkillCapabilities( "自动调用外部 API 获取数据,再运行 Python 脚本生成报告。", ), ["运行脚本、代码或命令", "调用外部 API 或第三方服务"], "必须识别当前工作台无法执行的能力承诺", ); assert.deepEqual( findUnsupportedSkillCapabilities( "审阅用户提供的 API 文档;不得调用外部 API 或运行本地脚本。", ), [], "允许审阅 API/脚本内容,也允许把禁止调用写入约束", ); assert.deepEqual( findUnsupportedSkillCapabilities( "指导用户如何调用 API,并要求用户手动运行脚本后粘贴结果。", ), [], "允许提供手动操作指导,只禁止 Skill 声称自行执行", ); console.log("✓ Skill 能力边界只拦截执行承诺,不误伤审阅类任务"); assert.equal( isSuggestionWithinNodeScope( "trigger", "当用户需要把会议记录整理成行动项时触发", ), true, ); assert.equal( isSuggestionWithinNodeScope( "trigger", "输出使用 Markdown 表格并包含责任人字段", ), false, "触发条件阶段不得推荐输出格式内容", ); assert.equal( isSuggestionWithinNodeScope( "output", "新增一个资源推荐模块并给出部署方案", ), false, "不得推荐五个节点以外的额外模块或部署方案", ); console.log("✓ 推荐内容严格归属于当前五节点之一"); assert.equal( evaluateSkillNodeQuality("steps", "分析内容并生成结果").complete, false, "没有明确顺序和多个动作的执行步骤不得完成", ); assert.equal( evaluateSkillNodeQuality( "steps", "先检查输入,再提取关键信息,然后生成结果并复核。", ).complete, true, "满足确定性标准的执行步骤应当完成", ); console.log("✓ 节点完成度由确定性质量标准复核"); const initialSuggestions = await jsonRequest( "/api/skills/builder/suggestions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skillName, skillDescription, nodes, }), }, ); assert.equal( initialSuggestions.source, "demo", "无 AI 配置时必须明确标记为演示建议", ); assert.equal( initialSuggestions.nodeKey, "trigger", "推荐结果必须明确声明所属节点", ); assert.ok( initialSuggestions.suggestions.length >= 2 && initialSuggestions.suggestions.length <= 4, "节点进入时必须返回 2-4 条可直接发送的建议", ); assert.equal( initialSuggestions.suggestions.some( (suggestion) => findUnsupportedSkillCapabilities(suggestion).length > 0 || !isSuggestionWithinNodeScope("trigger", suggestion), ), false, "节点推荐不得越出当前节点或包含不支持的能力", ); console.log("✓ 节点进入建议有明确来源和严格结构"); const guidanceTurn = await builderTurn("有哪些典型任务"); assert.equal( guidanceTurn.evaluation.intent, "request_guidance", "向 AI 索要示例时必须识别为求助意图", ); assert.equal( guidanceTurn.evaluation.updatedNodeKeys.length, 0, "求助问题不得写入任何 Skill 节点", ); assert.equal( nodes.filter((node) => node.completed).length, 0, "求助问题不得完成当前节点", ); assert.match( guidanceTurn.reply, /例如|比如|常见/, "AI 应先提供示例或解释,再继续追问", ); console.log("✓ 求助/索要示例不会写入或完成 Skill 节点"); const unsupportedTurn = await builderTurn( "执行步骤:调用外部 API 获取实时数据,再运行 Python 脚本生成报告。", ); assert.equal( unsupportedTurn.evaluation.intent, "request_guidance", "超出工作台能力边界的要求必须转为引导", ); assert.deepEqual( unsupportedTurn.evaluation.updatedNodeKeys, [], "脚本执行和外部 API 调用不得写入 Skill 节点", ); assert.match( unsupportedTurn.reply, /不支持|用户.*提供|手动/, "必须解释限制并提供对话内可执行的替代方案", ); 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( confirmedIncompleteTurn.reply, /[??]/, "确认写入的信息仍不足时 AI 必须继续追问", ); const multiNodeTurn = await builderTurn( [ "触发条件:当用户需要把产品需求整理成验收清单时触发,不处理闲聊。", "输入参数:必填产品需求和目标用户,可选优先级、平台与发布日期。", "执行步骤:先拆目标,再识别用户路径,然后生成正常、异常和边界场景。", "约束与测试:不得臆测未给出的业务规则,每条必须可独立执行并包含反例。", ].join(";"), ); assert.deepEqual( multiNodeTurn.evaluation.proposedNodeKeys, ["trigger", "inputs", "steps", "constraints"], "一条连贯消息涵盖多个节点时,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, 3, "第 4 节点缺失时,只能连续完成第 1-3 节点", ); assert.equal( nodes.find((node) => node.key === "constraints")?.ready, true, "第 5 节点的合格内容必须保留为待解锁", ); assert.equal( nodes.find((node) => node.key === "constraints")?.completed, false, "存在第 4 节点缺口时不得跳到第 5 节点", ); const filledGap = await builderTurn( "输出格式:输出 Markdown 表格,字段为编号、前置条件、操作、预期结果。", ); assert.deepEqual( filledGap.evaluation.proposedNodeKeys, ["output"], "补充缺口时 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, 5, "第 4 节点补齐后,已准备好的第 5 节点必须自动完成", ); assert.doesNotMatch( confirmedGap.reply, /约束与测试[^。]*[??]/, "全部节点自动完成后不得继续追问已经解锁的节点", ); console.log( "✓ 节点内容先展示提案,支持修改,确认后再写入并按顺序解锁", ); assert.deepEqual( getSkillNodeQualityIssues(nodes), [], "全部完成的五个节点必须通过发布质量标准", ); const lowQualityNodes = nodes.map((node) => node.key === "steps" ? { ...node, content: "分析内容并生成结果", completed: true } : node, ); const lowQualitySaveResponse = await fetch(`${baseUrl}/api/skills`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: `${skillName}质量测试`, description: skillDescription, nodes: lowQualityNodes, status: "active", }), }); assert.equal( lowQualitySaveResponse.status, 409, "即使前端标记 completed,低质量节点也不得发布", ); assert.match( (await lowQualitySaveResponse.json()).error, /尚未达到可发布标准|明确顺序/, ); console.log("✓ 保存接口独立复核五节点质量"); const duplicateNodeResponse = await fetch(`${baseUrl}/api/skills`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: `${skillName}重复节点测试`, description: skillDescription, nodes: nodes.map((node) => ({ ...node, key: "trigger" })), status: "active", }), }); assert.equal( duplicateNodeResponse.status, 400, "五个重复节点不得绕过固定流程校验", ); assert.match( JSON.stringify(await duplicateNodeResponse.json()), /必须且只能包含/, ); console.log("✓ 保存接口强制五种节点各一个"); const unsupportedNodes = nodes.map((node) => node.key === "steps" ? { ...node, content: "调用外部 API 获取实时数据,再运行 Python 脚本生成报告。", } : node, ); assert.ok( getSkillCapabilityViolations({ name: skillName, description: skillDescription, nodes: unsupportedNodes, }).length >= 2, "保存前能力校验必须定位违规节点", ); const unsupportedSaveResponse = await fetch(`${baseUrl}/api/skills`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: `${skillName}越界测试`, description: skillDescription, nodes: unsupportedNodes, status: "active", }), }); const unsupportedSavePayload = await unsupportedSaveResponse.json(); assert.equal( unsupportedSaveResponse.status, 409, "绕过 Builder 直接保存越界 Skill 时也必须拒绝", ); assert.match(unsupportedSavePayload.error, /不支持.*脚本.*API/); console.log("✓ 保存接口拒绝包含脚本/API 执行承诺的 Skill"); const created = await jsonRequest("/api/skills", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: skillName, description: skillDescription, nodes, status: "active", }), }); const skillId = created.skill.id; assert.ok(skillId, "必须自动创建 Skill"); console.log("✓ 自动创建 Skill"); const editedTurn = await builderTurn( "补充规则:当需求只有一句话时,先追问目标用户再生成清单。", ); assert.ok( 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", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: skillName, description: skillDescription, nodes, status: "active", }), }); const edited = await jsonRequest(`/api/skills/${skillId}`); assert.match(edited.skill.nodes[0].content, /目标用户/); console.log("✓ 只能通过 Builder 对话并由 AI 自动归类编辑 Skill 内容"); const chat = await jsonRequest("/api/chat"); assert.equal(chat.conversation.title, "", "新会话默认标题必须为空"); const firstChatClientRequestId = `acceptance_${crypto.randomUUID()}`; const firstChatRequestBody = { conversationId: chat.conversation.id, message: "产品需求 验收清单 输入参数 输出格式,请帮我整理。", selectedSkillIds: [skillId], clientRequestId: firstChatClientRequestId, }; const runPayload = await jsonRequest("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(firstChatRequestBody), }); const runId = runPayload.run.id; const firstResponse = await fetch( `${baseUrl}/api/chat/runs/${runId}/stream?after=0`, ); const firstEvents = await readFirstSseBatch(firstResponse); const cursor = Math.max(...firstEvents.map((event) => event.seq ?? 0)); assert.ok(cursor > 0, "断连前必须拿到可恢复序号"); await new Promise((resolve) => setTimeout(resolve, 180)); const resumedResponse = await fetch( `${baseUrl}/api/chat/runs/${runId}/stream?after=${cursor}`, ); const resumedEvents = await readSse(resumedResponse); const allEvents = [...firstEvents, ...resumedEvents]; const sequences = allEvents.map((event) => event.seq).filter(Boolean); assert.equal(new Set(sequences).size, sequences.length, "重连不得重复事件"); const usage = allEvents.find((event) => event.type === "skill_usage"); assert.deepEqual(usage?.skillIds, [skillId], "必须反馈实际用到的 Skill"); assert.equal(usage?.source, "keyword_fallback"); assert.ok(usage?.reason, "Skill 使用事件必须记录判断来源和原因"); const usageSummary = allEvents .filter((event) => event.type === "usage") .at(-1)?.usage; assert.equal( usageSummary?.status, "demo", "本地演示回答必须明确标记为零费用演示用量", ); assert.equal(usageSummary?.totalTokens, 0); assert.equal(usageSummary?.estimatedCostMicros, 0); assert.ok( usageSummary?.durationMs >= 0, "每轮回答必须记录端到端用时", ); const completedRetention = allEvents.find( (event) => event.type === "skill_retention", ); assert.equal( completedRetention?.state, "complete", "任务已交付完成时必须清空 Skill", ); assert.deepEqual(completedRetention?.skillIds, []); assert.ok( allEvents.some((event) => event.type === "complete"), "聊天必须完成", ); assert.ok( allEvents.some( (event) => event.type === "conversation_title" && event.title, ), "第一轮结束后必须自动生成标题", ); const generatedTitle = allEvents.find( (event) => event.type === "conversation_title", )?.title; assert.equal( generatedTitle, "产品需求与验收梳理", "标题必须提炼主题,不能直接截断用户原句", ); assert.ok(generatedTitle.length <= 18, "自动标题不得超过 18 个字符"); const chatAfterMeasuredRun = await jsonRequest("/api/chat"); const persistedUsage = chatAfterMeasuredRun.messages.find( (message) => message.id === runPayload.run.assistantMessageId, )?.usage; assert.equal( persistedUsage?.status, "demo", "刷新后必须恢复回答的 Token 与费用摘要", ); assert.equal( persistedUsage?.durationMs, usageSummary.durationMs, "刷新后必须恢复本轮用时", ); const messagesBeforeIdempotentRetry = chatAfterMeasuredRun.messages.length; const idempotentRetry = await jsonRequest("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(firstChatRequestBody), }); assert.equal( idempotentRetry.run.id, runPayload.run.id, "相同 clientRequestId 重试必须返回原 Run", ); assert.equal( (await jsonRequest("/api/chat")).messages.length, messagesBeforeIdempotentRetry, "幂等重试不得重复写入消息", ); console.log("✓ Skill 选择、实际调用反馈与 SSE 断线续传"); const unusedSkillPayload = await jsonRequest("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversationId: chat.conversation.id, message: "请写一句关于春天的问候。", selectedSkillIds: [skillId], clientRequestId: `acceptance_unused_skill_${crypto.randomUUID()}`, }), }); const unusedSkillEvents = await readSse( await fetch( `${baseUrl}/api/chat/runs/${unusedSkillPayload.run.id}/stream?after=0`, ), ); const unusedSkillUsage = unusedSkillEvents.find( (event) => event.type === "skill_usage", ); assert.deepEqual( unusedSkillUsage?.skillIds, [], "不相关任务不得调用已选择的 Skill", ); const unusedSkillStatuses = unusedSkillEvents .filter((event) => event.type === "status") .map((event) => event.label); assert.ok( unusedSkillStatuses.includes("正在思考"), "未调用 Skill 时应使用普通思考状态", ); assert.equal( unusedSkillStatuses.some((label) => /无需调用|未使用 Skill/.test(label)), false, "未调用 Skill 时不得展示内部路由结论", ); console.log("✓ 未调用 Skill 时只展示普通思考状态"); const staleUserMessageId = `stale_user_${crypto.randomUUID()}`; const staleAssistantMessageId = `stale_assistant_${crypto.randomUUID()}`; const staleRunId = `stale_run_${crypto.randomUUID()}`; const staleDatabase = new DatabaseSync(databasePath); staleDatabase.exec("PRAGMA busy_timeout = 5000;"); const insertSyntheticMessage = staleDatabase.prepare(` INSERT INTO messages (id, conversation_id, role, content, selected_skill_ids, used_skill_ids, status, error_message, usage_json, created_at) VALUES (?, ?, ?, ?, '[]', '[]', ?, '', '', ?) `); insertSyntheticMessage.run( staleUserMessageId, chat.conversation.id, "user", "模拟服务重启前的用户消息", "complete", "2026-01-01T00:00:00.000Z", ); insertSyntheticMessage.run( staleAssistantMessageId, chat.conversation.id, "assistant", "", "streaming", "2026-01-01T00:00:00.001Z", ); staleDatabase .prepare( `INSERT INTO chat_runs (id, client_request_id, conversation_id, user_message_id, assistant_message_id, status, cancel_requested, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'running', 0, ?, ?)`, ) .run( staleRunId, `stale_client_${crypto.randomUUID()}`, chat.conversation.id, staleUserMessageId, staleAssistantMessageId, "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z", ); staleDatabase.close(); const staleRunEvents = await readSse( await fetch(`${baseUrl}/api/chat/runs/${staleRunId}/stream?after=0`), ); assert.ok( staleRunEvents.some( (event) => event.type === "error" && /服务重启|超时/.test(event.message), ), "孤儿 Run 必须自动进入错误终态并关闭 SSE", ); const chatAfterStaleRun = await jsonRequest("/api/chat"); assert.equal( chatAfterStaleRun.messages.find( (message) => message.id === staleAssistantMessageId, )?.status, "error", "孤儿 Run 对应的助手消息必须标记失败", ); assert.equal(chatAfterStaleRun.continuation.state, "complete"); console.log("✓ 服务重启或超时遗留的孤儿 Run 会自动回收"); const seededSkills = await jsonRequest("/api/skills"); 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" }, body: JSON.stringify({ conversationId: chat.conversation.id, message: "这个 Skill 是干嘛的?", selectedSkillIds: [sqlReviewSkill.id], clientRequestId: `acceptance_explain_skill_${crypto.randomUUID()}`, }), }); const explainSkillEvents = await readSse( await fetch( `${baseUrl}/api/chat/runs/${explainSkillPayload.run.id}/stream?after=0`, ), ); const explainSkillUsage = explainSkillEvents.find( (event) => event.type === "skill_usage", ); const explainSkillContent = explainSkillEvents .filter((event) => event.type === "token") .map((event) => event.token) .join(""); assert.deepEqual( explainSkillUsage?.skillIds, [sqlReviewSkill.id], "询问已选 Skill 的用途时必须使用该 Skill 的真实资料", ); assert.match(explainSkillContent, /检查 SQL 的正确性、性能风险与数据安全边界/); assert.match(explainSkillContent, /当用户提供 SQL、查询计划或数据库变更/); assert.match(explainSkillContent, /任何 DELETE、UPDATE、DDL 建议必须附回滚方案/); assert.doesNotMatch( explainSkillContent, /报错弹窗|缺少 DLL|代理配置错误/, "Skill 介绍不得串用其他 Skill 的内容", ); 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" }, body: JSON.stringify({ conversationId: chat.conversation.id, message: "产品需求 验收清单:我还没提供具体内容,请先询问我需要补充的信息。", selectedSkillIds: [skillId, sqlReviewSkill.id], clientRequestId: `acceptance_follow_up_${crypto.randomUUID()}`, }), }); const followUpEvents = await readSse( await fetch( `${baseUrl}/api/chat/runs/${followUpPayload.run.id}/stream?after=0`, ), ); 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", "AI 要求用户补充必要信息时,任务必须保持继续状态", ); assert.deepEqual( followUpRetention?.skillIds, [skillId, sqlReviewSkill.id], "未完成的需求必须保留全部勾选 Skill,不能丢弃当轮未使用项", ); const chatWithRetainedSkill = await jsonRequest("/api/chat"); assert.deepEqual( chatWithRetainedSkill.retainedSkillIds, [skillId, sqlReviewSkill.id], "刷新页面后仍必须恢复待继续任务的 Skill", ); assert.equal( chatWithRetainedSkill.continuation.state, "awaiting_input", "刷新后必须恢复显式续接状态", ); assert.ok( chatWithRetainedSkill.continuation.expectedInput, "续接状态必须记录下一轮期待用户补充的内容", ); assert.equal( chatWithRetainedSkill.continuation.source, "deterministic", "明确追问应由确定性规则识别,不依赖 AI 猜测", ); 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", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversationId: chat.conversation.id, message: "需要", selectedSkillIds: [skillId, sqlReviewSkill.id], clientRequestId: `acceptance_continued_skill_${crypto.randomUUID()}`, }), }); const continuedEvents = await readSse( await fetch( `${baseUrl}/api/chat/runs/${continuedPayload.run.id}/stream?after=0`, ), ); const continuedUsage = continuedEvents.find( (event) => event.type === "skill_usage", ); assert.deepEqual( continuedUsage?.skillIds, [skillId, sqlReviewSkill.id], "用户用“需要”等短回复接受上一轮提议时必须继续使用保留的 Skill", ); assert.equal( continuedUsage?.source, "continuation", "省略回复必须通过显式续接状态恢复 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", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversationId: chat.conversation.id, message: "请生成一份很长的补充清单。", selectedSkillIds: [skillId], clientRequestId: `acceptance_stop_${crypto.randomUUID()}`, }), }); const conflictResponse = await fetch(`${baseUrl}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversationId: chat.conversation.id, message: "这条并发冲突消息不应写入数据库。", selectedSkillIds: [], clientRequestId: `acceptance_conflict_${crypto.randomUUID()}`, }), }); assert.equal( conflictResponse.status, 409, "同一会话同时只能创建一个活动 Run", ); const visibleStoppedContent = "客户端点击中止时已经显示的内容"; await jsonRequest(`/api/chat/runs/${stopPayload.run.id}/stop`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ visibleContent: visibleStoppedContent }), }); const stoppedEvents = await readSse( await fetch( `${baseUrl}/api/chat/runs/${stopPayload.run.id}/stream?after=0`, ), ); assert.ok( stoppedEvents.some((event) => event.type === "aborted"), "中止必须作为终态被记录", ); const chatAfterStop = await jsonRequest("/api/chat"); const stoppedMessage = chatAfterStop.messages.find( (message) => message.id === stopPayload.run.assistantMessageId, ); assert.equal(stoppedMessage?.status, "stopped"); assert.equal( stoppedMessage?.content, visibleStoppedContent, "中止后只保留用户点击时已经看到的内容", ); assert.ok( 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 === "这条并发冲突消息不应写入数据库。", ), false, "并发冲突事务不得留下孤立消息", ); console.log("✓ 中止原子落库且维持当前需求的 Skill 选择"); const renamed = await jsonRequest("/api/conversation", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "验收测试会话" }), }); assert.equal(renamed.conversation.title, "验收测试会话"); const reset = await jsonRequest("/api/conversation", { method: "DELETE" }); assert.equal(reset.conversation.title, ""); const resetChat = await jsonRequest("/api/chat"); assert.equal(resetChat.messages.length, 0); assert.equal(resetChat.continuation.state, "complete"); assert.deepEqual(resetChat.continuation.skillIds, []); console.log("✓ 标题编辑与会话重置"); await fetch(`${baseUrl}/api/skills/${skillId}`, { method: "DELETE" }).then( (response) => assert.equal(response.status, 204), ); const skillsAfterDelete = await jsonRequest("/api/skills"); assert.equal( skillsAfterDelete.skills.some((skill) => skill.id === skillId), false, ); console.log("✓ 删除 Skill 并刷新列表"); } try { await runAcceptance(); console.log("\n验收通过:生命周期、版本快照、中止竞态、创建编辑与重连均正常。"); } finally { server.kill("SIGTERM"); await new Promise((resolve) => { const timeout = setTimeout(resolve, 2_000); server.once("exit", () => { clearTimeout(timeout); resolve(); }); }); rmSync(tempDir, { recursive: true, force: true }); }