#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const path = require("node:path"); const { request } = require("playwright"); const TASK = "task557-page-ai-agent-descriptor-smoke"; const BASE_URL = ( process.env.MNOTE_UI_BASE_URL || process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000" ).replace(/\/+$/, ""); const ACTOR_ID = process.env.MNOTE_PAGE_AI_DESCRIPTOR_ACTOR_ID || `task557-descriptor-${Date.now().toString(36)}`; const PROFILE = process.env.MNOTE_PAGE_AI_DESCRIPTOR_PROFILE || "task557-descriptor-smoke"; const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); function descriptorById(payload) { return new Map( (payload.descriptors || []).map((descriptor) => [ descriptor.agentId, descriptor, ]), ); } function arrayIncludes(value, expected, message) { assert(Array.isArray(value), `${message}: 不是数组`); assert(value.includes(expected), `${message}: 缺少 ${expected}`); } function toolsByName(descriptor) { return new Map( (descriptor.tools || []).map((tool) => [ tool.name, tool, ]), ); } async function loadDescriptors(api) { const response = await api.get(`/api/page-ai/agents/descriptors?profile=${encodeURIComponent(PROFILE)}`, { timeout: REQUEST_TIMEOUT_MS, }); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch (error) { throw new Error(`descriptor response 不是 JSON: ${text.slice(0, 1000)}`); } assert(response.ok(), `descriptor API 失败: ${response.status()} ${text.slice(0, 1000)}`); return payload; } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const api = await request.newContext({ baseURL: BASE_URL, extraHTTPHeaders: { "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "user", accept: "application/json", }, }); try { const payload = await loadDescriptors(api); assert.equal(payload.schema, "mnote.ai_agent_descriptors.v1", "顶层 schema 不正确"); assert.equal(payload.profile, PROFILE, "profile 应回显 query profile"); assert.equal(payload.actorId, ACTOR_ID, "actorId 应使用请求 actor"); assert(Array.isArray(payload.descriptors), "descriptors 必须是数组"); assert.equal(payload.descriptors.length, 3, "应返回 Hermes / Reasonix / Chat-only 三类 agent"); const descriptors = descriptorById(payload); for (const agentId of ["hermes", "reasonix", "chat_only"]) { assert(descriptors.has(agentId), `缺少 ${agentId} descriptor`); assert.equal(descriptors.get(agentId).schema, "mnote.ai_agent_descriptor.v1", `${agentId} descriptor schema 不正确`); } const hermes = descriptors.get("hermes"); assert.equal(hermes.provider, "hermes_client", "Hermes provider 不正确"); assert.equal(hermes.canWriteFiles, true, "Hermes 应允许文件写入能力声明"); arrayIncludes(hermes.capabilities, "mnote_tools", "Hermes capabilities"); assert(toolsByName(hermes).has("mnote.knowledge_rag.query"), "Hermes tools 应包含 knowledge_rag.query"); const reasonix = descriptors.get("reasonix"); assert.equal(reasonix.provider, "acp_reasonix", "Reasonix provider 不正确"); assert.equal(reasonix.acpRuntime, "reasonix", "Reasonix acpRuntime 不正确"); assert.equal(reasonix.canWriteFiles, true, "Reasonix 应允许文件写入能力声明"); arrayIncludes(reasonix.capabilities, "native_patch", "Reasonix capabilities"); arrayIncludes(reasonix.capabilities, "knowledge_rag", "Reasonix capabilities"); const reasonixTools = toolsByName(reasonix); for (const toolName of [ "mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference", ]) { assert(reasonixTools.has(toolName), `Reasonix tools 缺少 ${toolName}`); assert.equal(reasonixTools.get(toolName).enabled, true, `${toolName} 默认应启用`); } assert( (reasonix.capabilityPacks || []).some((capability) => capability.id === "mnote-knowledge-rag" && capability.enabled === true), "Reasonix capabilityPacks 应包含启用的 mnote-knowledge-rag", ); assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix capabilityStates 应声明 knowledge_rag 默认启用"); const chatOnly = descriptors.get("chat_only"); assert.equal(chatOnly.provider, "chat_only", "Chat-only provider 不正确"); assert.equal(chatOnly.canWriteFiles, false, "Chat-only 不应声明文件写入"); assert.equal((chatOnly.tools || []).length, 0, "Chat-only 不应暴露 MNote tools"); arrayIncludes(chatOnly.capabilities, "chat", "Chat-only capabilities"); assert.deepEqual(chatOnly.defaultContextRefs || [], [], "Chat-only 默认不应携带 MNote context refs"); const toggleResponse = await api.put("/api/hermes/client/capabilities/toggle", { timeout: REQUEST_TIMEOUT_MS, headers: { "content-type": "application/json" }, data: { runtime: "mnote", profile: PROFILE, id: "mnote-knowledge-rag", enabled: false, }, }); const toggleText = await toggleResponse.text(); assert(toggleResponse.ok(), `禁用 knowledge rag capability 失败: ${toggleResponse.status()} ${toggleText.slice(0, 1000)}`); const disabledPayload = await loadDescriptors(api); const disabledReasonix = descriptorById(disabledPayload).get("reasonix"); assert.equal(disabledReasonix.capabilityStates?.knowledge_rag?.enabled, false, "禁用后 descriptor capabilityStates 应为 false"); assert(!(disabledReasonix.capabilities || []).includes("knowledge_rag"), "禁用后 Reasonix capabilities 不应继续声明 knowledge_rag"); assert((disabledReasonix.disabledCapabilities || []).includes("knowledge_rag"), "禁用后 disabledCapabilities 应包含 knowledge_rag"); assert.equal(toolsByName(disabledReasonix).get("mnote.knowledge_rag.query")?.enabled, false, "禁用后 knowledge_rag.query tool 应不可用"); const result = { ok: true, task: TASK, baseUrl: BASE_URL, profile: PROFILE, actorId: ACTOR_ID, agents: payload.descriptors.map((descriptor) => ({ agentId: descriptor.agentId, provider: descriptor.provider, canWriteFiles: descriptor.canWriteFiles, capabilityCount: (descriptor.capabilities || []).length, toolCount: (descriptor.tools || []).length, })), disabledKnowledgeRagVerified: true, }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } finally { await api.dispose().catch(() => undefined); } } main().catch((error) => { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.writeFileSync( path.join(OUTPUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, task: TASK, error: error.stack || error.message || String(error) }, null, 2)}\n`, "utf8", ); console.error(error.stack || error.message || String(error)); process.exit(1); });