#!/usr/bin/env node "use strict"; const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const OUTPUT_DIR = path.join(process.cwd(), "tmp", "task528-document-evidence-liteparse-agent-smoke"); const RESULT_PATH = path.join(OUTPUT_DIR, "result.json"); const ACTOR_ID = "mnote-e2e"; const TEST_PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; const RUN_REASONIX_ACP = process.env.MNOTE_TASK528_SKIP_REASONIX_ACP !== "1"; function fileUrl(localPath) { return `file://${localPath}`; } function localMdDocumentId(relativePath) { return `local-md:${relativePath}`; } async function fetchJson(pathname, init = {}) { const response = await fetch(`${BASE_URL}${pathname}`, init); const text = await response.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } assert( response.ok, `${pathname} 请求失败: ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`, ); return { payload, response }; } async function postJson(pathname, data, headers = {}) { return (await fetchJson(pathname, { method: "POST", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(data), })).payload; } async function putJson(pathname, data, headers = {}) { return (await fetchJson(pathname, { method: "PUT", headers: { "content-type": "application/json", ...headers }, body: JSON.stringify(data), })).payload; } async function getText(pathname, headers = {}, timeoutMs = 180_000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${BASE_URL}${pathname}`, { method: "GET", headers, signal: controller.signal, }); const text = await response.text(); assert(response.ok, `${pathname} 请求失败: ${response.status} ${text.slice(0, 500)}`); return text; } finally { clearTimeout(timer); } } async function signInCookie() { const { response } = await fetchJson("/api/auth", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "auth:signIn", args: { provider: "password", params: { account: ACTOR_ID, password: TEST_PASSWORD, flow: "signIn", }, }, }), }); const setCookie = response.headers.get("set-cookie") || ""; const session = setCookie.match(/mnote_session=[^;]+/u)?.[0]; assert(session, `登录响应缺少 mnote_session cookie: ${setCookie}`); return session; } async function createAiGrant(rootUri) { const payload = await postJson("/api/admin/access-policy/grants", { userId: ACTOR_ID, rootUri, permission: "write", recursive: true, capabilities: ["ai"], }, { "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "admin", }); assert(payload.grant?.id, "创建 AI 目录授权后缺少 grant id"); return payload.grant; } function assertEvidenceHit(hit, expected) { assert(hit, `${expected.label} 缺少正文级 PDF evidence 命中`); assert(String(hit.quote || "").includes("Printer test page"), `${expected.label} quote 不包含 PDF 正文 token`); assert.strictEqual(hit.source?.ownerDocumentPath, expected.ownerRel, `${expected.label} ownerDocumentPath`); assert.strictEqual(hit.source?.resourcePath, expected.pdfRel, `${expected.label} resourcePath`); assert.strictEqual(hit.source?.resourceKind, "pdf", `${expected.label} resourceKind`); assert(hit.source?.page, `${expected.label} 缺少 page locator`); assert(hit.source?.bbox, `${expected.label} 缺少 bbox locator`); assert(hit.source?.sourceMapPath, `${expected.label} 缺少 sourceMapPath`); } function decodeSseToolPayloads(sse) { return String(sse || "") .split(/\n\n+/u) .map((eventText) => { const eventName = eventText .split(/\n/u) .find((line) => line.startsWith("event:")) ?.slice("event:".length) .trim(); const dataLines = eventText .split(/\n/u) .filter((line) => line.startsWith("data:")) .map((line) => line.slice("data:".length).trimStart()); if (!dataLines.length) return null; try { return { event: eventName || null, payload: JSON.parse(dataLines.join("\n")) }; } catch { return null; } }) .filter(Boolean); } function assertReasonixAcpEvidenceSse(sse) { assert(sse.includes('"tool":"mnote_evidence_search"'), "Reasonix ACP SSE 未出现 mnote_evidence_search 工具调用"); assert(sse.includes("event: tool.completed"), "Reasonix ACP evidence 工具未标记为 completed"); assert(!sse.includes("event: tool.failed"), "Reasonix ACP evidence 工具被错误标记为 failed"); const payloads = decodeSseToolPayloads(sse); const completedTool = payloads.find(({ event, payload }) => event === "tool.completed" && payload?.status === "completed" ); assert(completedTool, "Reasonix ACP SSE 缺少 completed evidence tool payload"); const outputText = (completedTool.payload.output || []) .map((item) => item?.content?.text || "") .join("\n"); assert(outputText.includes('"quote":"Printer test page"'), "Reasonix ACP 工具结果未返回 PDF 正文 quote"); assert(outputText.includes('"page":1'), "Reasonix ACP 工具结果未返回 page locator"); assert(outputText.includes("mnote.agent_run_receipt.evidence.v1"), "Reasonix ACP 工具结果缺少 evidence run receipt"); } async function runReasonixAcpEvidenceCheck(input) { const { workspaceId, rootUri, documentId, actorHeaders } = input; await createAiGrant(rootUri); const sessionId = `task528_reasonix_tools_${Date.now().toString(36)}`; const traceId = `task528-reasonix-tools-${Date.now().toString(36)}`; const run = await postJson("/api/hermes/client/runs", { workspaceId, documentId, sessionId, sourceKind: "local_folder", rootUri, agentId: "reasonix", profile: "reasonix", acpRuntime: "reasonix", contextScope: "page", contextRefs: ["current_page", "folder"], allowedRoots: [{ rootUri, permission: "write" }], skillPreferences: { mnote: { "mnote-local-index": true, "mnote-chat-only": false, }, }, message: "请调用 mnote_evidence_search 搜索 Printer test page,然后用一句中文回答页码和 quote。必须使用工具,不能只说正在搜索。", traceId, pageContext: { contextScope: "page", node: { documentId, title: "EvidenceLive" }, aiContext: { schema: "mnote.page_ai_context.v1", workspaceId, documentId, scope: "page", selectedText: "", selectedBlockIds: [], contextBlocks: [], pageText: "", pageXml: ``, truncated: false, warnings: [], }, }, }, actorHeaders); assert(run.ok === true && run.runId, `Reasonix ACP run 创建失败: ${JSON.stringify(run)}`); const sse = await getText(`/api/hermes/client/events/${encodeURIComponent(run.runId)}`, actorHeaders); fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-tools-events.sse"), sse, "utf8"); assertReasonixAcpEvidenceSse(sse); fs.writeFileSync(path.join(OUTPUT_DIR, "reasonix-live-run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8"); return { runId: run.runId, sessionId: run.sessionId, eventPath: path.join(OUTPUT_DIR, "reasonix-tools-events.sse"), }; } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-evidence-live-")); const workspaceId = `local-ws:${ACTOR_ID}:task528-evidence`; const rootUri = fileUrl(root); const ownerRel = "EvidenceLive.md"; const pdfRel = "assets/default-testpage.pdf"; const documentId = localMdDocumentId(ownerRel); const actorHeaders = { "x-mnote-actor-id": ACTOR_ID, "x-mnote-actor-type": "user", "x-mnote-workspace-id": workspaceId, }; fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); fs.mkdirSync(path.join(root, "assets"), { recursive: true }); fs.writeFileSync( path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: ACTOR_ID, createdAt: new Date().toISOString(), capabilities: ["local_files", "ai_sessions", "markdown_edit"], }, null, 2)}\n`, "utf8", ); fs.copyFileSync("/usr/share/cups/data/default-testpage.pdf", path.join(root, pdfRel)); fs.writeFileSync( path.join(root, ownerRel), ["# Evidence Live", "", "测试正文级 PDF evidence。", "", `[Printer PDF](${pdfRel})`, ""].join("\n"), "utf8", ); const cookie = await signInCookie(); const toolsPayload = (await fetchJson("/api/hermes/client/tools?scope=mnote&profile=reasonix", { headers: { cookie }, })).payload; const toolNames = (toolsPayload.tools || []).map((tool) => tool.name); for (const name of [ "mnote.evidence.search", "mnote.evidence.read", "mnote.evidence.open", "mnote.index.status", "mnote.index.refresh", "mnote.index.update_settings", ]) { assert(toolNames.includes(name), `Reasonix tools 列表缺少 ${name}`); } const capabilitiesPayload = (await fetchJson("/api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=reasonix", { headers: { cookie }, })).payload; const mnoteCapabilities = (capabilitiesPayload.categories || []).flatMap((category) => category.capabilities || category.skills || []); assert( mnoteCapabilities.some((capability) => capability.id === "mnote-local-index" && capability.enabled !== false), "Reasonix agent 缺少启用的 mnote-local-index 能力", ); const settings = await putJson("/api/search/local-index/settings", { workspaceId, rootUri, includePaths: ["."], scheduleMode: "manual", runOnChange: false, }, actorHeaders); assert.strictEqual(settings.ok, true, "local evidence index settings ok"); const refresh = await postJson("/api/search/local-index/refresh", { workspaceId, rootUri }, actorHeaders); assert.strictEqual(refresh.ok, true, "local evidence index refresh ok"); const direct = await postJson("/api/evidence/search", { query: "Printer test page", scope: { workspaceId, rootUri, includeResources: true, includeOcr: true }, mode: "hybrid", topK: 5, }, actorHeaders); const directHit = (direct.results || []).find((result) => String(result.quote || "").includes("Printer test page")); assertEvidenceHit(directHit, { label: "direct", ownerRel, pdfRel }); const read = await postJson("/api/evidence/read", { locator: directHit.source, context: { beforeBlocks: 1, afterBlocks: 1, includeSectionSummary: true }, }, actorHeaders); assert.strictEqual(read.ok, true, "evidence read ok"); assert(String(read.quote || "").includes("Printer test page"), "evidence read 未读回 PDF 正文 quote"); const open = await postJson("/api/evidence/open", { locator: directHit.source }, actorHeaders); assert.strictEqual(open.ok, true, "evidence open ok"); assert(open.openAction?.params?.sourceMapPath, "evidence open 缺少 sourceMapPath params"); const toolEnvelope = await postJson("/api/hermes/tools/mnote/call", { toolName: "mnote.evidence.search", workspaceId, documentId, sourceKind: "local_folder", rootUri, actorId: ACTOR_ID, profile: "reasonix", sessionId: "task528_evidence_session", runId: "task528_evidence_run", toolCallId: "task528_evidence_tool_call", args: { query: "Printer test page", includeResources: true, includeOcr: true, topK: 5 }, }, actorHeaders); assert.strictEqual(toolEnvelope.ok, true, "MNote evidence tool envelope ok"); const toolResult = toolEnvelope.result || toolEnvelope; const toolHit = (toolResult.results || []).find((result) => String(result.quote || "").includes("Printer test page")); assertEvidenceHit(toolHit, { label: "agent-tool", ownerRel, pdfRel }); assert(String(toolHit.citationMarkdown || "").includes("](/documents/"), "agent tool 缺少可点击 citationMarkdown"); assert(String(toolHit.citationUrl || "").includes("resourceTab="), "agent tool citationUrl 缺少资源 tab 定位参数"); assert((toolEnvelope.audit?.evidenceIds || []).includes(toolHit.evidenceId), "agent tool audit 缺少 evidence id"); assert.strictEqual(toolEnvelope.audit?.runReceipt?.toolName, "mnote.evidence.search", "run receipt toolName"); const reasonixAcp = RUN_REASONIX_ACP ? await runReasonixAcpEvidenceCheck({ workspaceId, rootUri, documentId, actorHeaders }) : { skipped: true }; execFileSync(process.execPath, ["scripts/reasonix-acp-wrapper.mjs"], { cwd: process.cwd(), env: { ...process.env, MNOTE_REASONIX_ACP_SELFTEST: "1" }, stdio: ["ignore", "pipe", "pipe"], }); const parseMd = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.parse.md"); const sourceMap = path.join(root, "EvidenceLive.ocr", "default-testpage.pdf.source-map.json"); const sqlitePath = path.join(root, ".mnote", "index", "evidence.sqlite"); assert(fs.existsSync(parseMd), `缺少 LiteParse parse sidecar: ${parseMd}`); assert(fs.existsSync(sourceMap), `缺少 source-map sidecar: ${sourceMap}`); assert(fs.existsSync(sqlitePath), `缺少 evidence sqlite: ${sqlitePath}`); const ftsCount = Number(execFileSync( "sqlite3", [sqlitePath, "SELECT count(*) FROM evidence_fts WHERE evidence_fts MATCH 'Printer';"], { encoding: "utf8" }, ).trim()); assert(ftsCount >= 1, `evidence.sqlite FTS 未命中 PDF 正文: ${ftsCount}`); const result = { ok: true, baseUrl: BASE_URL, root, workspaceId, documentId, directEvidenceId: directHit.evidenceId, toolEvidenceId: toolHit.evidenceId, quote: directHit.quote, page: directHit.source.page, bbox: directHit.source.bbox, ownerDocumentPath: directHit.source.ownerDocumentPath, resourcePath: directHit.source.resourcePath, sourceMapPath: directHit.source.sourceMapPath, parseMd, sourceMap, sqlitePath, ftsCount, reasonixTools: toolNames.filter((name) => name.startsWith("mnote.evidence.")), evidenceSkillEnabled: true, receiptToolName: toolEnvelope.audit.runReceipt.toolName, reasonixAcp, }; fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); console.log(JSON.stringify(result, null, 2)); } main().catch((error) => { fs.mkdirSync(OUTPUT_DIR, { recursive: true }); fs.writeFileSync( path.join(OUTPUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: error.stack || error.message || String(error) }, null, 2)}\n`, "utf8", ); console.error(error.stack || error.message || String(error)); process.exit(1); });