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
@@ -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);
});