Files
mnote/scripts/task562-page-ai-reasonix-approval-plan-smoke.js
T
lix-2026 4a7efd4a30 feat: Page AI Reasonix desktop session alignment, settings IA cleanup, knowledge RAG hardening
- ACP client/session manager: Reasonix desktop live session context
- Hermes tools: knowledge_rag tool manifest and skill updates
- Browser runtime: sidebar page AI permission/profile/render/session/tree modules
- Routes: hermes_client, hermes_tools, knowledge_rag, web_shell
- Scripts: reasonix ACP wrapper, LightRAG MCP, smoke tasks 159/558/559/561/562
- Skills: mnote-knowledge-rag and mnote-lightrag-bridge SKILL.md updates
2026-06-13 22:20:01 +08:00

305 lines
18 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => { request.destroy(); retry(); });
};
const retry = () => {
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
setTimeout(tick, 250);
};
tick();
});
}
function writeFakeReasonixAcp(scriptPath) {
fs.writeFileSync(scriptPath, `
import * as readline from 'node:readline';
import { appendFileSync } from 'node:fs';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
const LOG = process.env.TASK562_FAKE_LOG || '';
let nextSessionId = 1;
let pendingPrompt = null;
let permissionRequestId = 7;
let permissionId = 'task562_perm_1';
let marker = 'TASK562_DONE';
function log(value) { if (LOG) appendFileSync(LOG, JSON.stringify(value) + '\\n', 'utf8'); }
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
log({ dir: 'in', method: msg.method || '', id: msg.id || null, hasResult: Boolean(msg.result), hasError: Boolean(msg.error) });
if (msg.method === 'initialize') {
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task562-fake-reasonix', version: '1.0' }, authMethods: [] } });
return;
}
if (msg.method === 'session/new') {
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task562_reasonix_session_' + nextSessionId++ } });
return;
}
if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || '';
const prompt = promptText(msg.params?.prompt);
marker = (String(prompt).match(/TASK562_[A-Z0-9_]+/) || ['TASK562_DONE'])[0];
pendingPrompt = { id: msg.id, sessionId, prompt };
send({
jsonrpc: '2.0',
id: permissionRequestId,
method: 'session/request_permission',
params: {
permissionId,
toolName: 'mcp__demo__write_file',
toolCall: {
toolCallId: 'gate-call_1',
title: 'bash',
kind: 'execute',
rawInput: { command: 'echo reasonix permission' }
},
options: [
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' }
]
}
});
log({ dir: 'out', method: 'session/request_permission', id: permissionRequestId, permissionId });
return;
}
if (msg.id === permissionRequestId && msg.result) {
log({ dir: 'permission-resolved', result: msg.result });
if (!pendingPrompt) return;
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: pendingPrompt.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
send({ jsonrpc: '2.0', id: pendingPrompt.id, result: { stopReason: 'end_turn' } });
pendingPrompt = null;
}
});
`, "utf8");
}
async function signIn(context, baseUrl) {
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json" },
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { account: ACTOR, password: PASSWORD, flow: "signIn" },
},
},
timeout: TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
const whoamiText = await whoami.text();
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
return JSON.parse(whoamiText);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
null,
{ timeout: TIMEOUT_MS },
);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs");
const fakeLog = path.join(OUT_DIR, "fake-reasonix-acp.jsonl");
writeFakeReasonixAcp(fakeScript);
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], env: { TASK562_FAKE_LOG: fakeLog }, title: "Reasonix Fake" }]);
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(ROOT, "rust"),
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" },
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task562-reasonix-approval-"));
let browser;
try {
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const viewer = await signIn(context, baseUrl);
const actorId = viewer.userId || ACTOR;
const suffix = Date.now().toString(36).toUpperCase();
const workspaceId = `local-ws:${actorId}:task562-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const relativePath = `Task562-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8");
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 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(workspaceId)}, ${sqlQuote(actorId)}, 'Task562 Reasonix Approval', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
`);
sqliteExec(`
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 (${sqlQuote(`grant_task562_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
`);
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("workspaceId", workspaceId);
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
await selectReasonix(page);
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-tab="reasonix-settings"]').first().click({ timeout: TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'reasonix-settings',
null,
{ timeout: TIMEOUT_MS },
);
const modelSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.model_id"]');
const approvalSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.approval_mode"]');
const planSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.plan_mode"]');
await modelSelect.selectOption("mimo-pro", { timeout: TIMEOUT_MS });
await approvalSelect.selectOption("ask", { timeout: TIMEOUT_MS });
await planSelect.selectOption("auto", { timeout: TIMEOUT_MS });
await page.waitForFunction(
() => {
const text = document.querySelector('[data-page-ai-model-status]')?.textContent || '';
return text.includes('Reasonix') && text.includes('mimo-pro') && text.includes('审批:询问') && text.includes('计划:自动');
},
null,
{ timeout: TIMEOUT_MS },
);
await page.evaluate(() => {
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
const buttons = Array.from(drawer ? drawer.querySelectorAll('[data-page-ai-tab="chat"]') : []);
const button = buttons.find((node) => node instanceof HTMLElement && node.offsetParent !== null);
if (button) button.click();
});
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'chat',
null,
{ timeout: TIMEOUT_MS },
);
await page.waitForSelector('[data-page-ai-reasonix-quick-controls]:not([hidden])', { timeout: TIMEOUT_MS });
const quickControlsText = await page.locator('[data-page-ai-reasonix-quick-controls]').textContent({ timeout: TIMEOUT_MS });
assert(String(quickControlsText || '').includes('模型'), `聊天输入区应显示模型选择: ${quickControlsText}`);
assert(String(quickControlsText || '').includes('审批'), `聊天输入区应显示审批选择: ${quickControlsText}`);
assert(String(quickControlsText || '').includes('Plan'), `聊天输入区应显示 Plan 选择: ${quickControlsText}`);
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.model_id"]').inputValue({ timeout: TIMEOUT_MS }), 'mimo-pro', '模型 quick control 应同步设置值');
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.approval_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'ask', '审批 quick control 应同步设置值');
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.plan_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'auto', 'Plan quick control 应同步设置值');
const marker = `TASK562_DONE_${suffix}`;
await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
await page.waitForFunction(
() => !!document.querySelector('[data-page-ai-permission-dialog]') && !document.querySelector('[data-page-ai-permission-dialog]')?.hidden,
null,
{ timeout: TIMEOUT_MS },
);
const permissionText = await page.locator('[data-page-ai-permission-dialog]').textContent({ timeout: TIMEOUT_MS });
assert(String(permissionText || '').includes('bash') && String(permissionText || '').includes('echo reasonix permission'), `权限弹窗应展示 tool 和命令: ${permissionText}`);
const resolveRequestPromise = page.waitForRequest(
(request) => request.url().includes('/resolve-permission') && request.method() === 'POST',
{ timeout: 5000 },
).catch(() => null);
const resolveResponsePromise = page.waitForResponse(
(response) => response.url().includes('/resolve-permission'),
{ timeout: 5000 },
).catch(() => null);
await page.evaluate(() => {
const button = document.querySelector('[data-page-ai-permission-dialog] [data-page-ai-permission-action="allow"]');
if (button instanceof HTMLElement) button.click();
});
const resolveRequest = await resolveRequestPromise;
assert(resolveRequest, '点击允许后应发出 resolve-permission 请求');
const resolveResponse = await resolveResponsePromise;
assert(resolveResponse && resolveResponse.ok(), `resolve-permission 应成功,实际 status=${resolveResponse && resolveResponse.status()} url=${resolveRequest.url()} body=${resolveRequest.postData() || ''}`);
await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS });
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
assert(runId, '缺少 runId');
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker, `AI 回复不符合预期: ${JSON.stringify(text && text.slice(0, 800))}`);
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
assert(String(runtimeStrip || '').includes('Reasonix'), `runtime strip 应显示 Reasonix: ${runtimeStrip}`);
assert(String(runtimeStrip || '').includes('mimo-pro'), `runtime strip 应显示已选 model: ${runtimeStrip}`);
assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${runtimeStrip}`);
assert(String(runtimeStrip || '').includes('计划:自动'), `runtime strip 应显示计划模式: ${runtimeStrip}`);
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert.equal(rows[0] && rows[0].status, 'completed', 'SQLite run status 应为 completed');
const screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
console.log(JSON.stringify(result, null, 2));
await context.close().catch(() => undefined);
} catch (error) {
fs.writeFileSync(path.join(OUT_DIR, 'failure.json'), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, 'utf8');
throw error;
} finally {
if (browser) await browser.close().catch(() => undefined);
server.kill('SIGTERM');
await new Promise((resolve) => server.once('exit', resolve));
if (process.env.MNOTE_KEEP_TASK562_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });