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 = []; async function builderTurn(message) { const clientRequestId = `acceptance_builder_${crypto.randomUUID()}`; const requestBody = (afterSeq) => JSON.stringify({ clientRequestId, afterSeq, skillId: null, message, skillName, skillDescription, 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; 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 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( nodes.filter((node) => node.completed).length, 0, "信息不足时节点不能提前完成", ); assert.match(incompleteTurn.reply, /[??]/, "信息不足时 AI 必须继续追问"); const multiNodeTurn = await builderTurn( [ "触发条件:当用户需要把产品需求整理成验收清单时触发,不处理闲聊。", "输入参数:必填产品需求和目标用户,可选优先级、平台与发布日期。", "执行步骤:先拆目标,再识别用户路径,然后生成正常、异常和边界场景。", "约束与测试:不得臆测未给出的业务规则,每条必须可独立执行并包含反例。", ].join(";"), ); assert.deepEqual( multiNodeTurn.evaluation.updatedNodeKeys, ["trigger", "inputs", "steps", "constraints"], "一条连贯消息涵盖多个节点时,AI 必须一次归类全部内容", ); 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.updatedNodeKeys, ["output"], "补充缺口时 AI 应更新第 4 节点", ); assert.equal( nodes.filter((node) => node.completed).length, 5, "第 4 节点补齐后,已准备好的第 5 节点必须自动完成", ); assert.doesNotMatch( filledGap.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.updatedNodeKeys.includes("trigger"), "编辑时也必须由 AI 自动判断目标节点", ); 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 安全审阅", ); assert.ok(sqlReviewSkill, "预置的 SQL 安全审阅 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 followUpPayload = await jsonRequest("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversationId: chat.conversation.id, message: "产品需求 验收清单:我还没提供具体内容,请先询问我需要补充的信息。", selectedSkillIds: [skillId], 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", ); assert.equal( followUpRetention?.state, "awaiting_input", "AI 要求用户补充必要信息时,任务必须保持继续状态", ); assert.deepEqual( followUpRetention?.skillIds, [skillId], "未完成的多轮任务必须保留实际使用的 Skill", ); const chatWithRetainedSkill = await jsonRequest("/api/chat"); assert.deepEqual( chatWithRetainedSkill.retainedSkillIds, [skillId], "刷新页面后仍必须恢复待继续任务的 Skill", ); assert.equal( chatWithRetainedSkill.continuation.state, "awaiting_input", "刷新后必须恢复显式续接状态", ); assert.ok( chatWithRetainedSkill.continuation.expectedInput, "续接状态必须记录下一轮期待用户补充的内容", ); assert.equal( chatWithRetainedSkill.continuation.source, "deterministic", "明确追问应由确定性规则识别,不依赖 AI 猜测", ); 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], 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], "用户用“需要”等短回复接受上一轮提议时必须继续使用保留的 Skill", ); assert.equal( continuedUsage?.source, "continuation", "省略回复必须通过显式续接状态恢复 Skill", ); 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: [], 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.messages.some( (message) => message.content === "这条并发冲突消息不应写入数据库。", ), false, "并发冲突事务不得留下孤立消息", ); console.log("✓ 中止被视为一轮完成"); 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 }); }