feat(page-ai): add skill context and agent profile policy

This commit is contained in:
lix-2026
2026-05-29 21:57:29 +08:00
parent 631205ba2f
commit 49a0545148
18 changed files with 3478 additions and 259 deletions
+146 -49
View File
@@ -39,7 +39,15 @@ function debugLog(message) {
const toolContextStorage = new AsyncLocalStorage();
function isWriteMnoteTool(toolName) {
return !['mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch'].includes(toolName);
return ![
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.doc.fetch',
'mnote.page.get',
'mnote.block.fetch',
].includes(toolName);
}
function stableIdPart(value, fallback) {
@@ -60,6 +68,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
idempotencyKey,
dryRun,
workspaceId,
documentId,
sourceKind,
rootUri,
profile,
capabilityScope,
...args
} = rawArgs || {};
const effectiveSessionId =
@@ -69,6 +82,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`;
const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`;
const writeTool = isWriteMnoteTool(toolName);
const capabilities = context.mnoteCapabilities || {};
if (!args.contextRefs && capabilities.contextRefs) args.contextRefs = capabilities.contextRefs;
if (!args.agentId && capabilities.agentId) args.agentId = capabilities.agentId;
if (!args.aiAccessScope && capabilities.aiAccessScope) args.aiAccessScope = capabilities.aiAccessScope;
if (!args.allowedRoots && capabilities.allowedRoots) args.allowedRoots = capabilities.allowedRoots;
const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false;
const effectiveIdempotencyKey =
idempotencyKey ||
@@ -76,8 +94,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
return {
toolName,
args,
workspaceId: workspaceId || context.workspaceId || 'default',
documentId: args.documentId || context.documentId,
workspaceId: workspaceId || context.workspaceId || capabilities.workspaceId || 'default',
documentId: documentId || args.documentId || context.documentId || capabilities.documentId,
sourceKind: sourceKind || args.sourceKind || context.sourceKind || capabilities.sourceKind,
rootUri: rootUri || args.rootUri || context.rootUri || capabilities.rootUri,
profile: profile || args.profile || context.profile || capabilities.profile,
actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
sessionId: effectiveSessionId,
runId: effectiveRunId,
@@ -85,16 +106,17 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
traceId: effectiveTraceId,
dryRun: effectiveDryRun,
idempotencyKey: effectiveIdempotencyKey,
capabilityScope: capabilityScope || args.capabilityScope || context.capabilityScope || capabilities.capabilityScope,
};
}
if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
const payload = buildMnoteToolPayload(
'mnote.doc.markdown_edit',
'mnote.context.read_current_page',
{
workspaceId: 'ws_demo',
documentId: 'doc_1',
operations: [{ search: '旧', replace: '新' }],
format: 'markdown',
},
{
actorId: 'user_1',
@@ -123,6 +145,44 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) {
throw new Error('selftest expected identity fields outside args');
}
const contextualPayload = buildMnoteToolPayload(
'mnote.context.read_current_page',
{ format: 'markdown' },
{
actorId: 'user_1',
workspaceId: 'ws_local',
documentId: 'local-md:Current.md',
runId: 'run_local_1',
traceId: 'trace_local_1',
mnoteCapabilities: {
agentId: 'reasonix',
contextRefs: [{ kind: 'current_page' }],
sourceKind: 'local_folder',
rootUri: 'file:///tmp/mnote-local',
profile: 'reasonix',
aiAccessScope: {
permissionLevel: 'read_write',
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'write' }],
allowedResourceIds: ['local-md:Current.md'],
},
},
},
);
if (contextualPayload.sourceKind !== 'local_folder') {
throw new Error('selftest expected sourceKind inherited from mnoteCapabilities');
}
if (contextualPayload.rootUri !== 'file:///tmp/mnote-local') {
throw new Error('selftest expected rootUri inherited from mnoteCapabilities');
}
if (contextualPayload.profile !== 'reasonix') {
throw new Error('selftest expected profile inherited from mnoteCapabilities');
}
if (!Array.isArray(contextualPayload.args.contextRefs) || contextualPayload.args.contextRefs[0]?.kind !== 'current_page') {
throw new Error('selftest expected contextRefs inherited from mnoteCapabilities');
}
if (contextualPayload.args.aiAccessScope?.permissionLevel !== 'read_write') {
throw new Error('selftest expected aiAccessScope inherited from mnoteCapabilities');
}
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
@@ -328,15 +388,17 @@ function emitUsage(sessionId, used, size) {
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs
const MNOTE_TOOL_NAMES = [
'mnote.doc.fetch',
'mnote.doc.markdown_edit',
'mnote.block.*',
'mnote.page.*',
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_doc_fetch: 'mnote.doc.fetch',
mnote_doc_markdown_edit: 'mnote.doc.markdown_edit',
mnote_skill_read: 'mnote.skill.read',
mnote_context_snapshot: 'mnote.context.snapshot',
mnote_context_resolve_target: 'mnote.context.resolve_target',
mnote_context_read_current_page: 'mnote.context.read_current_page',
};
async function callMnoteTool(toolName, args) {
@@ -361,10 +423,50 @@ async function callMnoteTool(toolName, args) {
// ── Register Tools ───────────────────────────────────
const tools = new ToolRegistry();
const chatOnlyTools = new ToolRegistry();
tools.register({
name: 'mnote_doc_fetch',
description: '读取当前 mnote 文档的 markdown 内容。返回文档标题和正文。',
name: 'mnote_skill_read',
description: '按需读取 MNote skill 正文。只有任务需要 MNote 能力时才调用。',
parameters: {
type: 'object',
properties: {
skillId: { type: 'string', description: 'MNote skill ID' },
agentId: { type: 'string', description: '当前 agent ID' },
},
required: ['skillId'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_skill_read, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_snapshot',
description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。',
parameters: {
type: 'object',
properties: {
contextRefs: { type: 'array', items: {} },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_snapshot, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_resolve_target',
description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。',
parameters: { type: 'object', properties: {} },
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_resolve_target, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_read_current_page',
description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。',
parameters: {
type: 'object',
properties: {
@@ -373,33 +475,7 @@ tools.register({
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_fetch, args),
parallelSafe: false,
});
tools.register({
name: 'mnote_doc_markdown_edit',
description: '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。',
parameters: {
type: 'object',
properties: {
documentId: { type: 'string', description: '文档 ID' },
operations: {
type: 'array',
items: {
type: 'object',
properties: {
search: { type: 'string', description: '要搜索的文本片段' },
replace: { type: 'string', description: '替换后的文本' },
},
required: ['search', 'replace'],
},
description: '搜索替换操作列表',
},
},
required: ['documentId', 'operations'],
},
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_markdown_edit, args),
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_read_current_page, args),
parallelSafe: false,
});
@@ -429,20 +505,36 @@ onRequest('session/new', async (params) => {
const sessionId = randomUUID();
const client = new DeepSeekClient({ apiKey: DEEPSEEK_API_KEY });
const systemPrompt = [
'You are a helpful AI assistant for editing Markdown documents.',
'Use mnote_doc_fetch to read the current document.',
'Use mnote_doc_markdown_edit to apply precise search/replace edits.',
'Always use mnote_doc_fetch first to understand the document content before editing.',
const chatSystemPrompt = [
'You are a helpful AI assistant inside MNote.',
'For ordinary chat, reply directly.',
'Do not read or edit MNote pages, files, folders, or attachments unless MNote capabilities are explicitly attached for this prompt.',
].join('\n');
const loop = new CacheFirstLoop({
const mnoteSystemPrompt = [
'You are a helpful AI assistant inside MNote.',
'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.',
'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.',
'<available-skills>',
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
'</available-skills>',
].join('\n');
const chatLoop = new CacheFirstLoop({
client,
tools: chatOnlyTools,
prefix: new ImmutablePrefix({ system: chatSystemPrompt, toolSpecs: chatOnlyTools.specs() }),
});
const mnoteLoop = new CacheFirstLoop({
client,
tools,
prefix: new ImmutablePrefix({ system: systemPrompt, toolSpecs: tools.specs() }),
prefix: new ImmutablePrefix({ system: mnoteSystemPrompt, toolSpecs: tools.specs() }),
});
sessions.set(sessionId, { id: sessionId, loop, client, aborter: null });
sessions.set(sessionId, { id: sessionId, chatLoop, mnoteLoop, client, aborter: null });
return { sessionId };
});
@@ -478,7 +570,12 @@ onRequest('session/prompt', async (params) => {
traceId: params.traceId || params.mnoteTraceId,
workspaceId: params.workspaceId,
documentId: params.documentId,
mnoteCapabilities: params.mnoteCapabilities || null,
};
const mnoteCapabilities = params.mnoteCapabilities || {};
const useMnoteTools = mnoteCapabilities.attachMnoteCapabilities === true;
const loop = useMnoteTools ? session.mnoteLoop : session.chatLoop;
const promptText = text;
let stopReason = 'end_turn';
let hasAssistantOutput = false;
let hasToolCall = false;
@@ -509,7 +606,7 @@ onRequest('session/prompt', async (params) => {
try {
await toolContextStorage.run(toolContext, async () => {
for await (const ev of session.loop.step(text)) {
for await (const ev of loop.step(promptText)) {
if (session.aborter?.signal.aborted) {
stopReason = 'cancelled';
break;
@@ -148,16 +148,73 @@ async function main() {
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: true,
active: "reasonix",
profiles: [{ name: "reasonix", label: "Reasonix", modelConfigured: true, apiKeyConfigured: true }],
active: "mnoteai",
profiles: [
{ name: "mnoteai", label: "MNote AI", modelConfigured: true, apiKeyConfigured: true },
{ name: "chemist", label: "Chemist", modelConfigured: true, apiKeyConfigured: true },
],
}),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
await page.route("**/api/hermes/client/profiles/active", async (route) => {
captured.push({ kind: "profile-active", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true, categories: [], archived: [] }),
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/skills/toggle", async (route) => {
captured.push({ kind: "skill-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
});
await page.route("**/api/hermes/client/skills**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/toggle")) {
captured.push({ kind: "skill-toggle", method: route.request().method(), body: route.request().postData() || "" });
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ ok: true }),
});
return;
}
const runtime = url.searchParams.get("runtime");
const profile = url.searchParams.get("profile") || "mnoteai";
const body = runtime === "mnote"
? {
ok: true,
runtime: "mnote",
categories: [{ name: "mnote", skills: [{ id: "mnote-current-page", name: "mnote-current-page", title: "当前页读取", description: "读取当前页", enabled: true, source: "mnote", origin: "builtin" }] }],
archived: [],
}
: runtime === "reasonix"
? {
ok: true,
runtime: "reasonix",
categories: [{ name: "project", skills: [{ id: "reasonix-review", name: "reasonix-review", description: "Reasonix review", enabled: true, toggleable: true, source: "reasonix", origin: "project" }] }],
archived: [],
}
: {
ok: true,
profile,
categories: [{
name: "writing",
skills: [
{ id: "hermes-builtin", name: "hermes-builtin", title: "Hermes builtin", description: `Builtin ${profile}`, enabled: true, source: "builtin", origin: "builtin" },
{ id: profile === "chemist" ? "hermes-chemist" : "hermes-writer", name: profile === "chemist" ? "hermes-chemist" : "hermes-writer", title: profile === "chemist" ? "Hermes chemist" : "Hermes writer", description: `Hermes ${profile}`, enabled: profile !== "chemist", source: "local", origin: "installed" },
],
}],
archived: [],
};
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
});
await page.route("**/api/hermes/client/sessions", async (route) => {
@@ -190,12 +247,40 @@ async function main() {
});
});
await page.route("**/api/hermes/client/events/*", async (route) => {
const includeReceipt = captured.filter((item) => item.kind === "run").length > 1;
const completed = { event: "run.completed", run_id: `run_task502_${suffix}`, output: "Task502 response" };
if (includeReceipt) {
completed.agentAudit = {
rootUri,
actorId: "mnote-e2e",
actorType: "user",
agentKind: "reasonix",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task502 receipt" }],
agentRunReceipt: {
schema: "mnote.agent_run_receipt.v1",
runId: `run_task502_${suffix}`,
sessionId: `mnote_task502_${suffix}`,
workspaceId,
documentId,
rootUri,
agentKind: "reasonix",
status: "completed",
permission: "write",
changedFiles: [{ path: relativePath, changeType: "modified", summary: "task502 receipt" }],
refresh: {
touchesCurrentFile: true,
currentDocumentId: documentId,
strategy: "refresh_current_file",
},
},
};
}
await route.fulfill({
status: 200,
headers: { "content-type": "text/event-stream; charset=utf-8" },
body:
`data: ${JSON.stringify({ event: "message.delta", run_id: `run_task502_${suffix}`, delta: "Task502 response" })}\n\n` +
`data: ${JSON.stringify({ event: "run.completed", run_id: `run_task502_${suffix}`, output: "Task502 response" })}\n\n`,
`data: ${JSON.stringify(completed)}\n\n`,
});
});
@@ -219,11 +304,34 @@ async function main() {
assert(!defaultChatText.includes("gateway:"), "默认聊天面不应显示 gateway 技术详情");
assert(!defaultChatText.includes("Hermes profile"), "默认聊天面不应显示 Hermes profile 技术项");
await page.locator("[data-page-ai-agent-selector]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator("[data-page-ai-agent-selector]").count(),
0,
"默认输入区不应继续平铺 agent selector,应收敛为一个 agent 按钮",
);
const agentButton = page.locator("[data-page-ai-agent-button]");
await agentButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await agentButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"agent 按钮应显示在输入区下方工具栏中",
);
const agentButtonLabel = await agentButton.getAttribute("aria-label");
assert(agentButtonLabel.includes("Agent"), `agent 按钮应提供当前 agent 摘要: ${agentButtonLabel}`);
await agentButton.click({ timeout: UI_TIMEOUT_MS });
const agentPopover = page.locator("[data-page-ai-agent-popover]");
await agentPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const agentIds = await page.$$eval("[data-page-ai-agent-id]", (nodes) =>
nodes.map((node) => node.getAttribute("data-page-ai-agent-id")).filter(Boolean),
);
assert.deepStrictEqual(agentIds.sort(), ["chat_only", "hermes", "reasonix"]);
await page.locator('[data-page-ai-agent-id="hermes"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-popover] [data-page-ai-profile-select]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-agent-popover] [data-page-ai-profile-select]').selectOption("chemist", { timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-profile") === "chemist",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-agent-id="reasonix"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-agent-id") === "reasonix",
@@ -232,15 +340,120 @@ async function main() {
);
const contextRefs = page.locator("[data-page-ai-context-refs]");
await contextRefs.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await contextRefs.count(),
0,
"默认输入区不应继续平铺 contextRef chip,应收敛为一个上下文按钮",
);
const contextButton = page.locator("[data-page-ai-context-button]");
await contextButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await page.locator('[data-page-ai-context-ref="current_page"][aria-pressed="true"]').isVisible(),
"当前页 contextRef 应默认勾选",
await contextButton.evaluate((node) => Boolean(node.closest(".wolai-page-ai-composer-bar"))),
"上下文按钮应显示在输入区下方工具栏中",
);
assert.strictEqual(
await page.locator('.wolai-page-ai-composer-bar [data-page-ai-action="history"]').count(),
0,
"历史会话不应继续占用输入区下方工具栏位置",
);
assert(
await page.locator("[data-page-ai-allowed-root]").first().isVisible(),
"SQLite 授权区域应作为 allowedRoot chip 显示",
await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').isVisible(),
"历史会话入口应移动到右上角设置旁边",
);
assert(
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').isVisible(),
"技能入口应显示在右上角历史按钮左侧",
);
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="skills"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="skills"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillPanelText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(skillPanelText.includes("MNote 内置技能"), "技能面板应展示 MNote 内置技能分组");
assert(skillPanelText.includes("Reasonix 技能"), "技能面板应展示 Reasonix 技能分组");
assert(skillPanelText.includes("Hermes 技能"), "技能面板应展示 Hermes 技能分组");
assert(skillPanelText.includes("Hermes chemist"), "Hermes 技能应随 chemist profile 加载");
assert(!skillPanelText.includes("Hermes writer"), "Hermes profile 切到 chemist 后不应继续显示上一 profile 技能");
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').count(),
0,
"MNote 技能分组折叠后不应显示组内技能",
);
await page.locator('[data-page-ai-skill-group-toggle="mnote"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-hide-hermes-builtin]').check({ timeout: UI_TIMEOUT_MS });
const hiddenBuiltinText = await page.locator('[data-page-ai-panel="skills"]').innerText({ timeout: UI_TIMEOUT_MS });
assert(!hiddenBuiltinText.includes("Hermes builtin"), "隐藏 Hermes 内置后不应显示 Hermes 内置技能");
await page.locator('[data-page-ai-hide-hermes-builtin]').uncheck({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-profile-select]').selectOption("mnoteai", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-writer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').count(),
0,
"Hermes profile 切回 mnoteai 后不应残留 chemist 技能",
);
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-profile-select]').selectOption("chemist", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const skillsScreenshot = await saveScreenshot(page, "00-skills-panel");
await page.locator('[data-page-ai-skill-group="mnote"] [data-page-ai-skill-toggle="mnote-current-page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="reasonix"] [data-page-ai-skill-toggle="reasonix-review"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-skill-group="hermes"] [data-page-ai-skill-toggle="hermes-chemist"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-skill-toggled") === "hermes-chemist",
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator('[data-page-ai-panel="skills"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const contextButtonText = await contextButton.innerText({ timeout: UI_TIMEOUT_MS });
assert(
contextButtonText.trim() === "⇅",
`上下文按钮应显示为单个上下文图标: ${contextButtonText}`,
);
const contextButtonLabel = await contextButton.getAttribute("aria-label");
assert(
contextButtonLabel.includes("当前页") && contextButtonLabel.includes("打开资源"),
`上下文按钮 aria-label 应摘要展示已选上下文: ${contextButtonLabel}`,
);
await contextButton.click({ timeout: UI_TIMEOUT_MS });
const contextPopover = page.locator("[data-page-ai-context-popover]");
await contextPopover.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="current_page"]:checked').isVisible(),
"当前页 contextRef 应在 popover checkbox 中默认勾选",
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
assert.strictEqual(
await page.locator(".wolai-page-ai-composer > [data-page-ai-allowed-roots]").count(),
0,
"授权区域不应继续在输入区显示黑色 chip,应收敛到上下文 popover 内",
);
await contextButton.click({ timeout: UI_TIMEOUT_MS });
assert(
(await contextPopover.innerText({ timeout: UI_TIMEOUT_MS })).includes("授权区域"),
"SQLite 授权区域应在上下文 popover 内展示",
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("[data-page-ai-input]").fill("收到请回复收到", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null,
{ timeout: UI_TIMEOUT_MS },
);
assert.strictEqual(
await page.locator("[data-page-ai-tool-card]").count(),
0,
"纯聊天短请求不应显示 MNote tool card",
);
const ackRuns = captured.filter((item) => item.kind === "run");
assert(ackRuns.length >= 1, "未捕获纯聊天 Page AI run payload");
const ackRunBody = JSON.parse(ackRuns[ackRuns.length - 1].body);
assert.strictEqual(ackRunBody.pageContext?.aiContext?.pageText, undefined, "Page AI run 不应默认上传 pageText");
assert.strictEqual(ackRunBody.pageContext?.aiContext?.pageXml, undefined, "Page AI run 不应默认上传 pageXml");
assert.strictEqual(ackRunBody.pageContext?.aiContext?.contextBlocks, undefined, "Page AI run 不应默认上传 contextBlocks");
assert.strictEqual(ackRunBody.pageContext?.evidence, undefined, "Page AI run 不应默认上传选区 evidence 正文");
assert.strictEqual(ackRunBody.selectedText, "", "Page AI run 不应默认上传 selectedText 正文");
await page.locator('.wolai-page-ai-header-actions [data-page-ai-tab="agent"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="agent"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -272,11 +485,24 @@ async function main() {
await page.locator('[data-page-ai-panel="chat-only-settings"] [data-page-ai-tab="chat"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-panel="chat"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="current_page"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="folder"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-context-ref="changed_files"]').click({ timeout: UI_TIMEOUT_MS });
await contextButton.click({ timeout: UI_TIMEOUT_MS });
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="current_page"]').click({ timeout: UI_TIMEOUT_MS });
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="folder"]').click({ timeout: UI_TIMEOUT_MS });
await contextPopover.locator('input[type="checkbox"][data-page-ai-context-ref="changed_files"]').click({ timeout: UI_TIMEOUT_MS });
const updatedContextButtonLabel = await contextButton.getAttribute("aria-label");
assert(
updatedContextButtonLabel.includes("打开资源") && updatedContextButtonLabel.includes("文件夹"),
`勾选变化后上下文按钮摘要应更新: ${updatedContextButtonLabel}`,
);
await contextPopover.locator('[data-page-ai-action="close-context-popover"]').click({ timeout: UI_TIMEOUT_MS });
const runsBeforeDocumentTask = captured.filter((item) => item.kind === "run").length;
await page.locator("[data-page-ai-input]").fill(`Task502 agent/context ${suffix}`, { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
const runWaitStarted = Date.now();
while (captured.filter((item) => item.kind === "run").length <= runsBeforeDocumentTask) {
assert(Date.now() - runWaitStarted < UI_TIMEOUT_MS, "未捕获第二次 Page AI run payload");
await page.waitForTimeout(50);
}
await page.waitForFunction(
() => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Task502 response"),
null,
@@ -289,12 +515,25 @@ async function main() {
assert.strictEqual(runBody.agentId, "reasonix");
assert(Array.isArray(runBody.contextRefs), "run payload 必须包含 contextRefs 数组");
assert(!runBody.contextRefs.some((item) => item.kind === "current_page"), "取消当前页后不应发送 current_page contextRef");
assert.strictEqual(runBody.pageContext?.aiContext?.pageText, undefined, "取消当前页后不应发送 pageText");
assert.strictEqual(runBody.pageContext?.aiContext?.pageXml, undefined, "取消当前页后不应发送 pageXml");
assert.strictEqual(runBody.pageContext?.aiContext?.contextBlocks, undefined, "取消当前页后不应发送 contextBlocks");
assert.strictEqual(runBody.pageContext?.aiContext?.pageText, undefined, "Page AI run 不应默认上传 pageText");
assert.strictEqual(runBody.pageContext?.aiContext?.pageXml, undefined, "Page AI run 不应默认上传 pageXml");
assert.strictEqual(runBody.pageContext?.aiContext?.contextBlocks, undefined, "Page AI run 不应默认上传 contextBlocks");
assert.strictEqual(runBody.pageContext?.evidence, undefined, "Page AI run 不应默认上传选区 evidence 正文");
assert.strictEqual(runBody.selectedText, "", "Page AI run 不应默认上传 selectedText 正文");
assert(runBody.contextRefs.some((item) => item.kind === "active_editor" && item.documentId === documentId));
assert(runBody.contextRefs.some((item) => item.kind === "folder" && item.rootUri === rootUri));
assert(runBody.contextRefs.some((item) => item.kind === "changed_files"));
assert.strictEqual(runBody.skillPreferences?.mnote?.["mnote-current-page"], false, "MNote skill 开关应进入 run payload");
assert.strictEqual(runBody.skillPreferences?.reasonix?.["reasonix-review"], false, "Reasonix skill 开关应进入 run payload");
const preferenceBodies = captured
.filter((item) => item.kind === "ui-preferences" && item.method === "PUT")
.map((item) => JSON.parse(item.body || "{}"));
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile_id"] === "chemist"), "Hermes profile 选择应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.mnote.enabled"]?.["mnote-current-page"] === false), "MNote skill 开关应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.reasonix.skills.enabled"]?.["reasonix-review"] === false), "Reasonix skill 开关应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.common.skills.groups.collapsed"]?.mnote === true), "技能分组折叠状态应写入 SQLite UI preference");
assert(preferenceBodies.some((body) => body.updates?.["ai.agent.hermes.profile.chemist.skills.hide_builtin"] === true), "Hermes 隐藏内置技能应按 profile 写入 SQLite UI preference");
assert(captured.some((item) => item.kind === "skill-toggle" && JSON.parse(item.body || "{}").profile === "chemist"), "Hermes skill 开关应按 profile 调用");
assert(Array.isArray(runBody.allowedRoots), "run payload 必须包含 allowedRoots 数组");
assert(runBody.allowedRoots.some((item) =>
item.rootUri === rootUri
@@ -302,6 +541,16 @@ async function main() {
&& item.source === "sqlite_directory_grant"
));
assert.strictEqual(runBody.runTargetSnapshot?.source, "open_editors_snapshot");
await page.waitForFunction(
() => document.documentElement.getAttribute("data-mnote-page-ai-receipt-current-refresh") === "true",
null,
{ timeout: UI_TIMEOUT_MS },
);
assert.strictEqual(
await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-receipt-filetree-refresh")),
"true",
"agentRunReceipt.changedFiles 应触发文件树事件驱动刷新",
);
const screenshot = await saveScreenshot(page, "01-agent-selector-context");
const result = {
@@ -312,6 +561,7 @@ async function main() {
rootUri,
documentId,
screenshot,
skillsScreenshot,
captured,
};
fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");