feat(page-ai): add resumable run journal descriptors

This commit is contained in:
lix-2026
2026-06-09 18:48:46 +08:00
parent 6ef233772e
commit 922965d30f
17 changed files with 3254 additions and 58 deletions
+111 -17
View File
@@ -27,10 +27,12 @@ import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1';
const REASONIX_BIN = process.env.MNOTE_REASONIX_BIN || 'reasonix';
const REASONIX_ACP_BACKEND = (process.env.MNOTE_REASONIX_ACP_BACKEND || 'auto').trim().toLowerCase();
function debugLog(message) {
if (DEBUG) process.stderr.write(`${message}\n`);
@@ -74,6 +76,75 @@ function assertSameSortedSet(actual, expected, label) {
}
}
function parseReasonixMajorVersion(versionText) {
const text = String(versionText || '');
const match = text.match(/\b(?:npm-v|v)?(\d+)(?:\.\d+){0,2}(?:[-+][\w.-]+)?\b/);
if (!match) return null;
const major = Number.parseInt(match[1], 10);
return Number.isFinite(major) ? major : null;
}
function readReasonixVersionText() {
try {
return execFileSync(REASONIX_BIN, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
} catch (error) {
debugLog(`[reasonix-acp-mnote] unable to read ${REASONIX_BIN} --version: ${error instanceof Error ? error.message : String(error)}`);
return '';
}
}
function shouldUseNativeReasonixAcp(versionText = readReasonixVersionText()) {
if (REASONIX_ACP_BACKEND === 'legacy') return false;
if (REASONIX_ACP_BACKEND === 'native') return true;
const major = parseReasonixMajorVersion(versionText);
return major !== null && major >= 1;
}
function nativeReasonixAcpArgs() {
const args = ['acp'];
const model = (process.env.REASONIX_MODEL || '').trim();
if (model) args.push('-model', model);
const extraArgsText = (process.env.MNOTE_REASONIX_ACP_ARGS_JSON || '').trim();
if (extraArgsText) {
let extraArgs;
try {
extraArgs = JSON.parse(extraArgsText);
} catch (error) {
throw new Error(`MNOTE_REASONIX_ACP_ARGS_JSON must be a JSON array of strings: ${error instanceof Error ? error.message : String(error)}`);
}
if (!Array.isArray(extraArgs) || extraArgs.some((value) => typeof value !== 'string')) {
throw new Error('MNOTE_REASONIX_ACP_ARGS_JSON must be a JSON array of strings');
}
args.push(...extraArgs);
}
return args;
}
function proxyNativeReasonixAcp(versionText = readReasonixVersionText()) {
const args = nativeReasonixAcpArgs();
debugLog(`[reasonix-acp-mnote] delegating to native ${REASONIX_BIN} ${args.join(' ')} (${versionText || 'version unknown'})`);
const child = spawn(REASONIX_BIN, args, {
stdio: ['inherit', 'inherit', 'inherit'],
env: process.env,
});
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(signal, () => {
if (!child.killed) child.kill(signal);
});
}
child.on('error', (error) => {
process.stderr.write(`FATAL: failed to spawn native Reasonix ACP (${REASONIX_BIN}): ${error.message}\n`);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code === null ? 1 : code);
});
}
function isWriteMnoteTool(toolName) {
return ![
'mnote.skill.read',
@@ -89,6 +160,22 @@ function isWriteMnoteTool(toolName) {
].includes(toolName);
}
function toolResultStatusFromContent(content) {
const text = String(content || '');
if (!text.trim()) return 'completed';
try {
const payload = JSON.parse(text);
if (payload && typeof payload === 'object') {
if (payload.ok === false) return 'failed';
if (payload.error && payload.error !== null) return 'failed';
return 'completed';
}
} catch {
// 非 JSON 工具结果按普通文本处理,只识别明确错误前缀。
}
return /^\s*(error|failed|exception)\b/i.test(text) ? 'failed' : 'completed';
}
function stableIdPart(value, fallback) {
const text = String(value || fallback || '')
.trim()
@@ -181,6 +268,15 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
}
if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (parseReasonixMajorVersion('reasonix 0.53.2') !== 0) {
throw new Error('selftest expected legacy Reasonix 0.x version parsing');
}
if (parseReasonixMajorVersion('reasonix npm-v1.4.0-rc.1') !== 1) {
throw new Error('selftest expected native Reasonix 1.x version parsing');
}
if (parseReasonixMajorVersion('unversioned') !== null) {
throw new Error('selftest expected unversioned Reasonix output to be unknown');
}
const payload = buildMnoteToolPayload(
'mnote.context.read_current_page',
{
@@ -331,6 +427,14 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
rustKnowledgeTools,
'selftest expected Reasonix wrapper tool mapping to match Rust knowledge RAG manifest tools',
);
const scriptDir = dirname(fileURLToPath(import.meta.url));
const knowledgeSkillPath = join(scriptDir, '..', 'skills', 'mnote-knowledge-rag', 'SKILL.md');
const knowledgeSkillText = readFileSync(knowledgeSkillPath, 'utf8');
for (const toolName of rustKnowledgeTools) {
if (!knowledgeSkillText.includes(toolName)) {
throw new Error(`selftest expected mnote-knowledge-rag skill text to mention ${toolName}`);
}
}
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
@@ -369,6 +473,11 @@ if (!DEEPSEEK_API_KEY) {
process.env.DEEPSEEK_API_KEY = DEEPSEEK_API_KEY;
const reasonixVersionText = readReasonixVersionText();
if (shouldUseNativeReasonixAcp(reasonixVersionText)) {
proxyNativeReasonixAcp(reasonixVersionText);
} else {
// ── Dynamic import of reasonix public API ────────────
let CacheFirstLoop, DeepSeekClient, ToolRegistry, ImmutablePrefix;
@@ -533,22 +642,6 @@ function emitToolResult(sessionId, toolCallId, status, text) {
});
}
function toolResultStatusFromContent(content) {
const text = String(content || '');
if (!text.trim()) return 'completed';
try {
const payload = JSON.parse(text);
if (payload && typeof payload === 'object') {
if (payload.ok === false) return 'failed';
if (payload.error && payload.error !== null) return 'failed';
return 'completed';
}
} catch {
// 非 JSON 工具结果按普通文本处理,只识别明确错误前缀。
}
return /^\s*(error|failed|exception)\b/i.test(text) ? 'failed' : 'completed';
}
function emitUsage(sessionId, used, size) {
emitSessionUpdate(sessionId, {
sessionUpdate: 'usage_update',
@@ -1129,3 +1222,4 @@ for (const raw of queuedRpcLines.splice(0)) {
await handleRpcLine(raw);
}
process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);
}
@@ -19,6 +19,47 @@ const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
.find((candidate) => fs.existsSync(candidate));
async function assertKnowledgeRagDescriptor(requestContext) {
const response = await requestContext.get(`${BASE_URL}/api/page-ai/agents/descriptors?profile=task529-knowledge-rag`);
assert(response.ok(), `descriptor API 失败: ${response.status()} ${await response.text()}`);
const payload = await response.json();
const reasonix = (payload.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix");
assert(reasonix, "descriptor 缺少 Reasonix");
assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled");
assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag");
const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name));
for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) {
assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`);
}
}
async function status(requestContext) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const response = await requestContext.get(`${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(response.ok(), `knowledge-rag status 失败: ${response.status()} ${await response.text()}`);
return await response.json();
}
async function ensureIndexed(requestContext) {
const ingestResponse = await requestContext.post(`${BASE_URL}/api/knowledge-rag/ingest`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: [{ sourcePath: EXPECTED_RESOURCE }],
},
});
assert(ingestResponse.ok(), `knowledge-rag ingest 失败: ${ingestResponse.status()} ${await ingestResponse.text()}`);
const startedAt = Date.now();
let lastEntry = null;
while (Date.now() - startedAt < 90_000) {
const payload = await status(requestContext);
lastEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === EXPECTED_RESOURCE) || null;
if (lastEntry?.indexedAtMs && lastEntry?.lightRagDocId && !lastEntry?.stale && !lastEntry?.deletedAtMs) return lastEntry;
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
throw new Error(`等待目标资料入库超时: ${JSON.stringify(lastEntry, null, 2)}`);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: CHROMIUM_EXECUTABLE_PATH });
@@ -38,6 +79,8 @@ async function main() {
},
});
assert(auth.ok(), `登录失败: ${auth.status()} ${await auth.text()}`);
await assertKnowledgeRagDescriptor(context.request);
await ensureIndexed(context.request);
const queryResponse = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, {
data: {
@@ -22,6 +22,7 @@ const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = `file://${ROOT_PATH}`;
const OWNER_REL = "knowledge-rag-fixtures-7-50/PageAiKnowledgeRagSmoke.md";
const EXPECTED_RESOURCE = "新页面233155/image copy 6.png";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
@@ -79,6 +80,75 @@ async function signIn(context) {
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
}
async function assertKnowledgeRagDescriptor(context) {
const response = await context.request.get(`${BASE_URL}/api/page-ai/agents/descriptors?profile=reasonix`, {
headers: {
"x-mnote-actor-id": ACTOR_ID,
"x-mnote-actor-type": "user",
accept: "application/json",
},
});
assert(response.ok(), `descriptor API 失败: ${response.status()} ${await response.text()}`);
const payload = await response.json();
const reasonix = (payload.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix");
assert(reasonix, "descriptor 缺少 Reasonix");
assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled");
assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag");
const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name));
for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) {
assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`);
}
}
async function knowledgeRagStatus(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const response = await context.request.get(`${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
assert(response.ok(), `knowledge-rag status 失败: ${response.status()} ${await response.text()}`);
return await response.json();
}
async function ensureKnowledgeRagSourceIndexed(context) {
const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/ingest`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
sources: [{ sourcePath: EXPECTED_RESOURCE }],
},
});
assert(response.ok(), `knowledge-rag ingest 失败: ${response.status()} ${await response.text()}`);
const startedAt = Date.now();
let lastEntry = null;
while (Date.now() - startedAt < 90_000) {
const payload = await knowledgeRagStatus(context);
lastEntry = (payload.registry?.entries || []).find((entry) => entry.sourceRootRelativePath === EXPECTED_RESOURCE) || null;
if (lastEntry?.indexedAtMs && lastEntry?.lightRagDocId && !lastEntry?.stale && !lastEntry?.deletedAtMs) return lastEntry;
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
throw new Error(`等待目标资料入库超时: ${JSON.stringify(lastEntry, null, 2)}`);
}
async function assertKnowledgeRagQueryReturnsCitationMarkdown(context) {
const response = await context.request.post(`${BASE_URL}/api/knowledge-rag/query`, {
data: {
rootUri: ROOT_URI,
workspaceId: WORKSPACE_ID,
query: "这个图片资料在资料库里是什么内容?",
mode: "mix",
topK: 8,
chunkTopK: 8,
includeChunkContent: true,
sourcePaths: [EXPECTED_RESOURCE],
},
});
assert(response.ok(), `knowledge-rag query 失败: ${response.status()} ${await response.text()}`);
const payload = await response.json();
const references = Array.isArray(payload.references) ? payload.references : [];
assert(
references.some((reference) => String(reference.citationMarkdown || "").trim() && String(reference.citationUrl || "").includes("resourceTab=")),
`query 输出缺少 citationMarkdownPage AI 最终回答 smoke 不能继续: ${JSON.stringify(references, null, 2).slice(0, 3000)}`,
);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -219,6 +289,9 @@ async function main() {
try {
await signIn(context);
await assertKnowledgeRagDescriptor(context);
await ensureKnowledgeRagSourceIndexed(context);
await assertKnowledgeRagQueryReturnsCitationMarkdown(context);
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", ROOT_URI);
@@ -232,7 +305,7 @@ async function main() {
.count();
const prompt = [
"请调用 mnote_knowledge_rag_query 检索资料库。",
"问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 [\"新页面233155/image copy 6.png\"]。",
`问题:这个图片资料在资料库里是什么内容?调用工具时请把 sourcePaths 参数设为 ["${EXPECTED_RESOURCE}"]。`,
"最终只输出一句中文结论,必须包含返回的 citationMarkdown 链接;如果 locatorDegraded=true,必须说明来源定位降级,不要描述检索过程,不要输出 raw JSON,不要编造页码或 bbox。",
].join("\n");
await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
@@ -60,6 +60,19 @@ async function signIn(context) {
assert(response.ok(), `登录失败: ${response.status()} ${await response.text()}`);
}
async function assertKnowledgeRagDescriptor(context) {
const result = await apiJson(context, "GET", `${BASE_URL}/api/page-ai/agents/descriptors?profile=task538-knowledge-rag`);
assert(result.ok, `descriptor API 失败: ${result.status} ${result.text.slice(0, 1000)}`);
const reasonix = (result.payload?.descriptors || []).find((descriptor) => descriptor.agentId === "reasonix");
assert(reasonix, "descriptor 缺少 Reasonix");
assert.equal(reasonix.capabilityStates?.knowledge_rag?.enabled, true, "Reasonix descriptor 应声明 knowledge_rag enabled");
assert((reasonix.capabilities || []).includes("knowledge_rag"), "Reasonix descriptor capabilities 应包含 knowledge_rag");
const toolNames = new Set((reasonix.tools || []).map((tool) => tool.name));
for (const toolName of ["mnote.knowledge_rag.status", "mnote.knowledge_rag.query", "mnote.knowledge_rag.open_reference"]) {
assert(toolNames.has(toolName), `Reasonix descriptor 缺少 ${toolName}`);
}
}
async function status(context) {
const params = new URLSearchParams({ rootUri: ROOT_URI, workspaceId: WORKSPACE_ID });
const result = await apiJson(context, "GET", `${BASE_URL}/api/knowledge-rag/status?${params.toString()}`);
@@ -149,6 +162,7 @@ async function main() {
const context = await request.newContext({ baseURL: BASE_URL });
try {
await signIn(context);
await assertKnowledgeRagDescriptor(context);
await ingest(context, [sourceA, sourceB]);
const indexedStatus = await waitForIndexed(context, [sourceA, sourceB]);
const scoped = await query(context, marker, [sourceA]);
@@ -0,0 +1,168 @@
#!/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);
});
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = process.env.MNOTE_REPO_ROOT || "/mnt/Data1T/mnote";
const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers"));
const TASK = "task557-page-ai-run-resume-smoke";
const OUTPUT_DIR = path.join(ROOT, "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const SCREENSHOT_PATH = path.join(OUTPUT_DIR, "page-ai-run-resume.png");
const CONTROL_PLANE_DB =
process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const RESUME_TIMEOUT_MS = Number(process.env.MNOTE_PAGE_AI_RESUME_TIMEOUT_MS || 90_000);
const ACTOR_ID = "mnote-e2e";
const WORKSPACE_ID = "local-ws:mnote-e2e:my-space";
const ROOT_PATH = "/mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-space";
const ROOT_URI = `file://${ROOT_PATH}`;
const OWNER_REL = "knowledge-rag-fixtures-7-57/PageAiRunResumeSmoke.md";
const DOCUMENT_ID = `local-md:${OWNER_REL.replaceAll("/", "~2F")}`;
function sqlQuote(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function sqliteExec(sql) {
execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function ensureFixture() {
assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`);
const ownerPath = path.join(ROOT_PATH, OWNER_REL);
fs.mkdirSync(path.dirname(ownerPath), { recursive: true });
fs.writeFileSync(ownerPath, "# Page AI Run Resume Smoke\n\n用于验证 Page AI host run journal afterSeq 恢复。\n", "utf8");
const now = new Date().toISOString();
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(ACTOR_ID)}, 'mnote.e2e@example.com', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(ACTOR_ID)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ACTOR_ID)}, 'MNote E2E Space', 'personal', ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES ('grant_task557_page_ai_run_resume', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(ROOT_URI)}, ${sqlQuote(ROOT_PATH)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(ACTOR_ID)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
`);
}
function seedRun() {
const stamp = Date.now();
const runId = `run_task557_resume_${stamp}`;
const sessionId = `mnote_task557_resume_${stamp}`;
const now = new Date().toISOString();
const runtimeJson = JSON.stringify({ runId, status: "running", lastEvent: "message.delta" });
const payloadJson = JSON.stringify({
requestId: `task557_resume_${stamp}`,
agentId: "reasonix",
message: "resume smoke user prompt",
});
sqliteExec(`
INSERT OR REPLACE INTO ai_runtime_runs
(id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision)
VALUES
(${sqlQuote(`arr_${stamp}`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'Resume smoke', 'reasonix', 'reasonix', ${sqlQuote(`trace_task557_${stamp}`)}, 'running', ${sqlQuote(runtimeJson)}, ${sqlQuote(payloadJson)}, NULL, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
DELETE FROM ai_runtime_events WHERE user_id=${sqlQuote(ACTOR_ID)} AND run_id=${sqlQuote(runId)};
INSERT INTO ai_runtime_events
(id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at)
VALUES
(${sqlQuote(`are_${stamp}_1`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', '{"delta":"old-token"}', ${sqlQuote(now)}),
(${sqlQuote(`are_${stamp}_2`)}, ${sqlQuote(ACTOR_ID)}, ${sqlQuote(WORKSPACE_ID)}, ${sqlQuote(DOCUMENT_ID)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', '{"delta":"resumed-token"}', ${sqlQuote(now)});
`);
return { runId, sessionId };
}
async function signIn(context) {
const response = await context.request.post(`${BASE_URL}/api/auth`, {
data: {
action: "auth:signIn",
args: {
provider: "password",
params: {
account: ACTOR_ID,
password: process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!",
flow: "signIn",
},
},
},
timeout: UI_TIMEOUT_MS,
});
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${await response.text()}`);
}
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
ensureFixture();
const seeded = seedRun();
const browser = await chromium.launch({
headless: process.env.MNOTE_PAGE_AI_VERIFY_HEADED !== "1",
executablePath: fs.existsSync(CHROME) ? CHROME : undefined,
});
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
const page = await context.newPage();
const pageErrors = [];
const consoleErrors = [];
const apiRequests = [];
const apiResponses = [];
page.on("pageerror", (error) => pageErrors.push(String(error?.message || error)));
page.on("console", (message) => {
if (["error", "warning"].includes(message.type())) consoleErrors.push(`${message.type()}: ${message.text()}`);
});
page.on("request", (request) => {
if (/\/api\/(page-ai|hermes\/client)/.test(request.url())) {
apiRequests.push({ method: request.method(), url: request.url() });
}
});
page.on("response", (response) => {
if (/\/api\/(page-ai|hermes\/client)/.test(response.url())) {
apiResponses.push({ status: response.status(), url: response.url() });
}
});
try {
await signIn(context);
await page.addInitScript(({ documentId, runId, sessionId }) => {
window.localStorage.setItem(`hermes_page_ai_session:${documentId}:active-run`, JSON.stringify({
schema: "mnote.page_ai_active_run_snapshot.v1",
hostRunId: runId,
sessionId,
lastSeq: "000000000000000001",
status: "running",
createdAt: Date.now(),
updatedAt: Date.now(),
}));
}, { documentId: DOCUMENT_ID, runId: seeded.runId, sessionId: seeded.sessionId });
const documentUrl = new URL(`${BASE_URL}/documents/${encodeURIComponent(DOCUMENT_ID)}`);
documentUrl.searchParams.set("sourceKind", "local_folder");
documentUrl.searchParams.set("rootUri", ROOT_URI);
documentUrl.searchParams.set("workspaceId", WORKSPACE_ID);
await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
try {
await page.waitForFunction(
(runId) => document.documentElement.getAttribute("data-mnote-page-ai-run-journal-resumed") === runId,
seeded.runId,
{ timeout: RESUME_TIMEOUT_MS },
);
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-active-run-last-seq") === "000000000000000002",
null,
{ timeout: RESUME_TIMEOUT_MS },
);
} catch (error) {
const debugState = await page.evaluate(() => {
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
return {
attrs: Object.fromEntries(Array.from(document.documentElement.attributes).map((attr) => [attr.name, attr.value])),
drawerHidden: drawer ? drawer.hidden : null,
drawerText: drawer ? (drawer.textContent || "").slice(0, 1000) : "",
storageKeys: Object.keys(window.localStorage || {}).filter((key) => key.includes("page_ai") || key.includes("hermes_page_ai")),
};
}).catch((stateError) => ({ stateError: String(stateError) }));
debugState.apiRequests = apiRequests;
debugState.apiResponses = apiResponses;
debugState.pageErrors = pageErrors;
debugState.consoleErrors = consoleErrors;
await page.screenshot({ path: path.join(OUTPUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined);
fs.writeFileSync(path.join(OUTPUT_DIR, "failure-state.json"), `${JSON.stringify(debugState, null, 2)}\n`, "utf8");
throw error;
}
const state = await page.evaluate((runId) => {
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
const text = drawer ? drawer.textContent || "" : "";
return {
resumedRunId: document.documentElement.getAttribute("data-mnote-page-ai-run-journal-resumed") || "",
activeHostRunId: document.documentElement.getAttribute("data-mnote-page-ai-active-host-run-id") || "",
lastSeq: document.documentElement.getAttribute("data-mnote-page-ai-active-run-last-seq") || "",
hasResumedToken: text.includes("resumed-token"),
hasSkippedToken: text.includes("old-token"),
runStatus: document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "",
drawerText: text.slice(0, 500),
expectedRunId: runId,
};
}, seeded.runId);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
assert.equal(state.resumedRunId, seeded.runId, `未标记 journal resumed: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.activeHostRunId, seeded.runId, `active host run id 不匹配: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.lastSeq, "000000000000000002", `lastSeq 未推进: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.hasResumedToken, true, `未渲染 afterSeq 后的新事件: ${JSON.stringify(state, null, 2)}`);
assert.equal(state.hasSkippedToken, false, `重复渲染了 afterSeq 之前的事件: ${JSON.stringify(state, null, 2)}`);
assert.deepEqual(pageErrors, [], `页面异常: ${pageErrors.join("\n")}`);
const result = {
ok: true,
task: TASK,
baseUrl: BASE_URL,
documentId: DOCUMENT_ID,
runId: seeded.runId,
sessionId: seeded.sessionId,
state,
consoleErrors,
apiRequests,
apiResponses,
screenshot: SCREENSHOT_PATH,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
} finally {
await browser.close().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);
});