feat(page-ai): add resumable run journal descriptors
This commit is contained in:
@@ -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 输出缺少 citationMarkdown,Page 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user