388 lines
15 KiB
JavaScript
388 lines
15 KiB
JavaScript
#!/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: `<page id=\"${documentId}\"></page>`,
|
|
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);
|
|
});
|