' +
@@ -3240,9 +3286,10 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) {
+ var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
return '' +
'
' +
- '
' + escapeHtml(item.role === 'user' ? '你' : 'AI') + '
' +
+ '
' + escapeHtml(roleLabel) + '
' +
'
' + escapeHtml(item.content || '') + '
' +
'
';
}).join('');
@@ -3258,6 +3305,15 @@ const SIDEBAR_TREE_JS: &str = r##"
drawer.hidden = false;
pageUiState.pageAiOpen = true;
updatePageAiTriggerState();
+ pageAiEnsureHermesSession().then(function() {
+ return pageAiRestoreHermesSession();
+ }).catch(function(error) {
+ pageUiState.pageAiMessages.push({
+ role: 'assistant',
+ content: 'Hermes 当前不可用:' + (error instanceof Error ? error.message : String(error))
+ });
+ renderPageAiConversation();
+ });
}
function closePageAiDrawer() {
@@ -3290,7 +3346,14 @@ const SIDEBAR_TREE_JS: &str = r##"
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
});
- if (eventName) onEvent(eventName, dataLines.join('\n'));
+ var payloadText = dataLines.join('\n');
+ if (!eventName && payloadText) {
+ try {
+ var parsed = JSON.parse(payloadText);
+ eventName = parsed && parsed.event ? String(parsed.event) : '';
+ } catch (_) {}
+ }
+ if (eventName) onEvent(eventName, payloadText);
});
}
}
@@ -3299,39 +3362,35 @@ const SIDEBAR_TREE_JS: &str = r##"
if (pageUiState.pageAiBusy) return;
var prompt = searchText(text);
if (!prompt) return;
- pageAiLoadSessions();
- var contextSnapshot = currentPageAiContextSnapshot();
- var aggregate = contextSnapshot.aggregate || {};
- var body = contextSnapshot.body || {};
- var subtree = contextSnapshot.subtree || null;
- var outline = subtree && subtree.outline ? subtree.outline : null;
pageUiState.pageAiBusy = true;
- pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
- var currentSession = pageAiCurrentSession();
- if (currentSession) {
- if (currentSession.title === '新会话') {
- currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
- }
- currentSession.messages = pageUiState.pageAiMessages.slice();
- currentSession.updatedAt = Date.now();
- }
- pageAiPersistSessions();
- renderPageAiConversation();
+ var currentSession = null;
try {
- var response = await fetch('/api/ai-agent/run', {
+ await pageAiEnsureHermesSession();
+ var contextSnapshot = currentPageAiContextSnapshot();
+ var aggregate = contextSnapshot.aggregate || {};
+ var body = contextSnapshot.body || {};
+ var subtree = contextSnapshot.subtree || null;
+ var outline = subtree && subtree.outline ? subtree.outline : null;
+ pageUiState.pageAiMessages.push({ role: 'user', content: prompt });
+ currentSession = pageAiCurrentSession();
+ if (currentSession) {
+ if (currentSession.title === '新会话') {
+ currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt;
+ }
+ currentSession.messages = pageUiState.pageAiMessages.slice();
+ currentSession.updatedAt = Date.now();
+ }
+ renderPageAiConversation();
+ var response = await fetch('/api/hermes/client/runs', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
- stream: true,
- maxSteps: 8,
- scope: 'document',
- messages: [{ role: 'user', content: prompt }],
- toolChoice: {
- mode: 'auto',
- toolSets: ['toolset.readonly', 'toolset.rag_read', 'toolset.docs_read', 'toolset.media_read', 'toolset.doc_read', 'toolset.doc_write', 'toolset.slash_write']
- },
- context: {
- documentId: currentDocumentId(),
+ workspaceId: resolveWorkspaceId(document.body),
+ documentId: currentDocumentId(),
+ sessionId: pageUiState.pageAiActiveSessionId,
+ message: prompt,
+ model: 'hermes-agent',
+ pageContext: {
documentBlocks: body.content || null,
node: {
documentId: currentDocumentId(),
@@ -3343,23 +3402,54 @@ const SIDEBAR_TREE_JS: &str = r##"
evidence: null,
pageOptions: currentPageOptions()
},
- options: {
- searxng: true,
- ai: { provider: pageUiState.pageAiProvider }
- }
+ selectedBlockId: null,
+ selectedText: null,
+ traceId: 'page-ai-run-' + Date.now().toString(36)
})
});
if (!response.ok) {
- throw new Error('page_ai_failed_' + response.status);
+ var errorPayload = await response.json().catch(function(){ return null; });
+ throw new Error(errorPayload && errorPayload.code ? errorPayload.code : 'page_ai_failed_' + response.status);
+ }
+ var runPayload = await response.json().catch(function(){ return null; });
+ var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload;
+ var runId = upstream && (upstream.run_id || upstream.runId);
+ if (!runId) throw new Error('hermes_run_missing_run_id');
+ var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), {
+ headers: { 'accept': 'text/event-stream' }
+ });
+ if (!eventResponse.ok) {
+ var eventError = await eventResponse.json().catch(function(){ return null; });
+ throw new Error(eventError && eventError.code ? eventError.code : 'hermes_events_failed_' + eventResponse.status);
}
var assistantText = '';
- await streamPageAiResponse(response, function(eventName, payloadText) {
- if (eventName === 'assistant_message') {
+ await streamPageAiResponse(eventResponse, function(eventName, payloadText) {
+ if (eventName === 'assistant_message' || eventName === 'message.delta') {
try {
var payload = JSON.parse(payloadText || 'null');
- assistantText = searchText(payload && payload.text);
+ assistantText += searchText((payload && (payload.text || payload.delta)) || '');
+ } catch (_) {
+ assistantText += searchText(payloadText);
+ }
+ }
+ if (eventName === 'run.completed') {
+ try {
+ var completed = JSON.parse(payloadText || 'null');
+ if (completed && completed.output) assistantText = searchText(completed.output);
} catch (_) {}
}
+ if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') {
+ try {
+ var toolEvent = JSON.parse(payloadText || 'null');
+ pageUiState.pageAiMessages.push({
+ role: 'tool',
+ content: String(toolEvent && (toolEvent.name || toolEvent.tool || eventName) || eventName)
+ });
+ } catch (_) {
+ pageUiState.pageAiMessages.push({ role: 'tool', content: eventName });
+ }
+ renderPageAiConversation();
+ }
});
pageUiState.pageAiMessages.push({
role: 'assistant',
@@ -3370,18 +3460,16 @@ const SIDEBAR_TREE_JS: &str = r##"
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
- pageAiPersistSessions();
} catch (error) {
pageUiState.pageAiMessages.push({
role: 'assistant',
- content: pageAiProviderLabel(pageUiState.pageAiProvider) + ' 当前请求失败:' + (error instanceof Error ? error.message : String(error))
+ content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error))
});
currentSession = pageAiCurrentSession();
if (currentSession) {
currentSession.messages = pageUiState.pageAiMessages.slice();
currentSession.updatedAt = Date.now();
}
- pageAiPersistSessions();
} finally {
pageUiState.pageAiBusy = false;
renderPageAiConversation();
@@ -3937,6 +4025,20 @@ const SIDEBAR_TREE_JS: &str = r##"
return;
}
+ var pageAiIntent = closestAction(e.target, '[data-page-ai-intent]');
+ if (pageAiIntent) {
+ e.preventDefault();
+ var intentName = pageAiIntent.getAttribute('data-page-ai-intent') || '';
+ if (intentName === 'create-summary') {
+ void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_summary,为当前页面创建或更新 AI Summary。');
+ return;
+ }
+ if (intentName === 'create-ai-note') {
+ void sendPageAiMessage('请通过 Hermes 调用 mnote.artifact.create_ai_note,基于当前页面创建一篇新的 AI Note。');
+ return;
+ }
+ }
+
var pageAiProvider = closestAction(e.target, '[data-page-ai-provider]');
if (pageAiProvider) {
e.preventDefault();
diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs
index 0047c1b5..adf1e273 100644
--- a/rust/crates/mnote-web/src/ssr/styles.rs
+++ b/rust/crates/mnote-web/src/ssr/styles.rs
@@ -2690,6 +2690,13 @@ body {
gap: 8px;
}
+.wolai-page-ai-intents {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 6px;
+}
+
.wolai-page-ai-suggestion-list,
.wolai-page-ai-toolbar {
display: flex;
diff --git a/scripts/task-hermes-page-ai-artifact-smoke.js b/scripts/task-hermes-page-ai-artifact-smoke.js
new file mode 100644
index 00000000..2600ee5b
--- /dev/null
+++ b/scripts/task-hermes-page-ai-artifact-smoke.js
@@ -0,0 +1,285 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+const { chromium } = require("playwright");
+const {
+ BASE_URL,
+ UI_TIMEOUT_MS,
+ cleanupDocuments,
+ createTempDocument,
+ ensureAuthenticated,
+ renameDocument,
+ requestJson,
+} = require("./tree-shell-smoke-helpers");
+
+async function callArtifact(request, target, suffix, toolName, args) {
+ return requestJson(request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: {
+ toolName,
+ workspaceId: target.workspaceId,
+ documentId: target.documentId,
+ actorId: "smoke-user",
+ sessionId: `mnote_smoke_artifact_${suffix}`,
+ runId: `run_smoke_artifact_${suffix}`,
+ toolCallId: `call_${toolName.replace(/\W+/g, "_")}_${suffix}`,
+ traceId: `trace_smoke_artifact_${suffix}`,
+ idempotencyKey: `idem_${toolName.replace(/\W+/g, "_")}_${suffix}`,
+ dryRun: false,
+ capabilityScope: ["artifact.write"],
+ args,
+ },
+ });
+}
+
+function assertArtifactReferenceEdge(edgesPayload, sourceDocumentId, artifactDocumentId, artifactType) {
+ const edges = Array.isArray(edgesPayload?.result?.edges) ? edgesPayload.result.edges : [];
+ const edge = edges.find(
+ (candidate) =>
+ candidate &&
+ candidate.fromNodeId === sourceDocumentId &&
+ candidate.toNodeId === artifactDocumentId &&
+ candidate.metadata?.kind === "ai_artifact_reference",
+ );
+ assert(edge, `未查询到 ${artifactDocumentId} 的 ai_artifact_reference edge`);
+ assert.equal(edge.edgeType, "source_of", `${artifactDocumentId} reference edge 类型不对`);
+ assert.equal(edge.metadata.artifactType, artifactType, `${artifactDocumentId} artifactType 不对`);
+ assert.equal(edge.metadata.projectionOnlyGroup, "AI Artifacts", `${artifactDocumentId} projection group 不对`);
+}
+
+async function main() {
+ const suffix = Date.now().toString(36);
+ const title = `TEST-HERMES-AI-artifact-${suffix}`;
+ const quickSessionId = `mnote_smoke_artifact_quick_${suffix}`;
+ let quickRunIndex = 0;
+ const quickRunBodies = [];
+ const createdIds = [];
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage();
+
+ try {
+ await page.route("**/api/hermes/tools/mnote/call", async (route) => {
+ throw new Error(`页面 AI artifact 快捷入口不应直连 mnote tool route: ${route.request().url()}`);
+ });
+ await page.route("**/api/hermes/client/sessions", async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId: quickSessionId,
+ title: "当前页问答",
+ traceId: `trace_quick_session_${suffix}`,
+ }),
+ });
+ });
+ await page.route(`**/api/hermes/client/sessions/${quickSessionId}`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId: quickSessionId,
+ session: { sessionId: quickSessionId, messages: [] },
+ }),
+ });
+ });
+ await page.route("**/api/hermes/client/runs", async (route) => {
+ quickRunIndex += 1;
+ quickRunBodies.push(JSON.parse(route.request().postData() || "{}"));
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId: quickSessionId,
+ runId: `run_quick_artifact_${suffix}_${quickRunIndex}`,
+ traceId: `trace_quick_artifact_${suffix}_${quickRunIndex}`,
+ }),
+ });
+ });
+ await page.route("**/api/hermes/client/events/**", async (route) => {
+ const runId = route.request().url().split("/").pop();
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "text/event-stream; charset=utf-8" },
+ body:
+ `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: quickSessionId, delta: "artifact intent queued" })}\n\n` +
+ `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: quickSessionId, output: "artifact intent queued" })}\n\n`,
+ });
+ });
+
+ await ensureAuthenticated(page, context.request);
+ const target = await createTempDocument(context.request);
+ createdIds.push(target.documentId);
+ await renameDocument(context.request, target.workspaceId, target.documentId, title);
+
+ const summary = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", {
+ summary: `摘要 ${suffix}`,
+ });
+ const summaryRetry = await callArtifact(context.request, target, suffix, "mnote.artifact.create_summary", {
+ summary: `摘要 ${suffix}`,
+ });
+ const aiNote = await callArtifact(context.request, target, suffix, "mnote.artifact.create_ai_note", {
+ content: `AI Note ${suffix}`,
+ });
+ const aiNoteSecond = await callArtifact(context.request, target, `${suffix}_second`, "mnote.artifact.create_ai_note", {
+ content: `AI Note second ${suffix}`,
+ });
+ const artifactDocumentIds = Array.from(
+ new Set([
+ summary.result.artifactDocumentId,
+ aiNote.result.artifactDocumentId,
+ aiNoteSecond.result.artifactDocumentId,
+ ]),
+ );
+ createdIds.push(...artifactDocumentIds);
+
+ for (const result of [summary, aiNote, aiNoteSecond]) {
+ assert.equal(result.ok, true, `${result.toolName} 应成功`);
+ assert.equal(result.audit.effect, "write", `${result.toolName} audit 应为 write`);
+ assert.equal(result.result.commandName, "tree.node.create", `${result.toolName} 必须走 tree.node.create`);
+ assert.equal(result.audit.commandId, result.result.commandId, `${result.toolName} audit commandId 必须指向 Rust commandId`);
+ assert(result.result.artifactDocumentId, `${result.toolName} 缺少 artifactDocumentId`);
+ assert.equal(result.result.referenceEdge?.from, target.documentId, `${result.toolName} reference edge from 不对`);
+ assert.equal(result.result.referenceEdge?.to, result.result.artifactDocumentId, `${result.toolName} reference edge to 不对`);
+ assert.equal(result.result.referenceEdge?.kind, "ai_artifact_reference", `${result.toolName} reference edge kind 不对`);
+ assert(result.result.artifacts?.commandLog, `${result.toolName} 缺少 commandLog artifact`);
+ assert(result.result.artifacts?.domainEvent, `${result.toolName} 缺少 domainEvent artifact`);
+ assert.equal(
+ result.result.artifacts.domainEvent.eventType,
+ "tree.node.created",
+ `${result.toolName} domain event 类型不对`,
+ );
+ }
+ assert.equal(summaryRetry.result.commandId, summary.result.commandId, "summary 同 idempotencyKey 重试必须返回同一 commandId");
+ assert.equal(
+ summaryRetry.result.artifactDocumentId,
+ summary.result.artifactDocumentId,
+ "summary 同页面重试必须指向同一 summary document",
+ );
+ assert.notEqual(
+ aiNoteSecond.result.artifactDocumentId,
+ aiNote.result.artifactDocumentId,
+ "ai_note 多次创建必须生成独立 artifact document",
+ );
+
+ const edgePayload = await requestJson(
+ context.request,
+ `/api/kernel/edges?workspaceId=${encodeURIComponent(target.workspaceId)}&nodeId=${encodeURIComponent(target.documentId)}`,
+ { method: "GET" },
+ );
+ assertArtifactReferenceEdge(edgePayload, target.documentId, summary.result.artifactDocumentId, "summary");
+ assertArtifactReferenceEdge(edgePayload, target.documentId, aiNote.result.artifactDocumentId, "ai_note");
+ assertArtifactReferenceEdge(edgePayload, target.documentId, aiNoteSecond.result.artifactDocumentId, "ai_note");
+
+ const fileProjection = await requestJson(
+ context.request,
+ `/api/tree/projections/file?workspaceId=${encodeURIComponent(target.workspaceId)}&rootNodeId=${encodeURIComponent(target.documentId)}&depth=2`,
+ { method: "GET" },
+ );
+ const projectionItems = Array.isArray(fileProjection?.result?.items) ? fileProjection.result.items : [];
+ const aiArtifactsMeta = fileProjection?.result?.meta?.aiArtifacts || {};
+ assert.equal(aiArtifactsMeta.title, "AI Artifacts", "AI Artifacts projection meta 标题不对");
+ assert.equal(aiArtifactsMeta.projectionOnly, true, "AI Artifacts 必须是 projection-only 分组");
+ assert.equal(aiArtifactsMeta.kernelNodeId, null, "AI Artifacts 不应是真实 kernel node");
+ assert(
+ !projectionItems.some((item) => item && item.nodeId === "AI Artifacts"),
+ "AI Artifacts 不应出现在 projection items 中作为真实 node",
+ );
+ for (const artifactDocumentId of artifactDocumentIds) {
+ assert(
+ aiArtifactsMeta.artifactDocumentIds?.includes(artifactDocumentId),
+ `AI Artifacts projection meta 缺少 ${artifactDocumentId}`,
+ );
+ }
+
+ const auditPayload = await requestJson(
+ context.request,
+ `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(`trace_smoke_artifact_${suffix}`)}`,
+ {
+ method: "GET",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ },
+ );
+ const auditEvents = Array.isArray(auditPayload.events) ? auditPayload.events : [];
+ for (const result of [summary, aiNote]) {
+ assert(
+ auditEvents.some(
+ (event) =>
+ event &&
+ event.phase === "completed" &&
+ event.toolCallId === result.toolCallId &&
+ event.audit?.commandId === result.result.commandId,
+ ),
+ `${result.toolName} audit 未串到 Hermes tool call 和 Rust command`,
+ );
+ }
+
+ await page.goto(
+ `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
+ { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
+ );
+ await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
+ await page.locator('[data-page-ai-intent="create-summary"]').click({ timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(
+ () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("artifact intent queued"),
+ undefined,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ await page.locator('[data-page-ai-intent="create-ai-note"]').click({ timeout: UI_TIMEOUT_MS });
+ const deadline = Date.now() + UI_TIMEOUT_MS;
+ while (quickRunBodies.length < 2 && Date.now() < deadline) {
+ await page.waitForTimeout(100);
+ }
+ assert.equal(quickRunBodies.length, 2, "两个 artifact 快捷入口都必须发送 Hermes run intent");
+ assert(
+ quickRunBodies[0].message.includes("mnote.artifact.create_summary"),
+ "创建 Summary 快捷入口必须发送 Hermes artifact summary intent",
+ );
+ assert(
+ quickRunBodies[1].message.includes("mnote.artifact.create_ai_note"),
+ "创建 AI Note 快捷入口必须发送 Hermes artifact ai_note intent",
+ );
+ assert(
+ quickRunBodies.every((body) => body.documentId === target.documentId && body.sessionId === quickSessionId),
+ "artifact 快捷入口必须携带当前页面 documentId 与 Hermes sessionId",
+ );
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ documentId: target.documentId,
+ workspaceId: target.workspaceId,
+ summaryArtifactType: summary.result.artifactType,
+ aiNoteArtifactType: aiNote.result.artifactType,
+ summaryCommand: summary.result.commandName,
+ aiNoteCommand: aiNote.result.commandName,
+ summaryArtifactDocumentId: summary.result.artifactDocumentId,
+ aiNoteArtifactDocumentId: aiNote.result.artifactDocumentId,
+ aiNoteSecondArtifactDocumentId: aiNoteSecond.result.artifactDocumentId,
+ referenceEdgeCount: edgePayload.result.edges.length,
+ aiArtifactsMeta,
+ auditEventCount: auditEvents.length,
+ quickIntentMessages: quickRunBodies.map((body) => body.message),
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await cleanupDocuments(context.request, createdIds).catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task-hermes-page-ai-audit-smoke.js b/scripts/task-hermes-page-ai-audit-smoke.js
new file mode 100644
index 00000000..63f0f561
--- /dev/null
+++ b/scripts/task-hermes-page-ai-audit-smoke.js
@@ -0,0 +1,207 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+const { chromium } = require("playwright");
+const {
+ BASE_URL,
+ cleanupDocuments,
+ createTempDocument,
+ ensureAuthenticated,
+ renameDocument,
+ requestJson,
+} = require("./tree-shell-smoke-helpers");
+
+async function main() {
+ const suffix = Date.now().toString(36);
+ const title = `TEST-HERMES-AI-audit-${suffix}`;
+ const marker = `TEST-HERMES-AI-AUDIT-${suffix}`;
+ const createdIds = [];
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage();
+
+ try {
+ await ensureAuthenticated(page, context.request);
+ const target = await createTempDocument(context.request);
+ createdIds.push(target.documentId);
+ await renameDocument(context.request, target.workspaceId, target.documentId, title);
+
+ const readTraceId = `trace_audit_read_${suffix}`;
+ const writeTraceId = `trace_audit_write_${suffix}`;
+ const deniedTraceId = `trace_audit_denied_${suffix}`;
+ const readToolCallId = `call_audit_read_${suffix}`;
+ const writeToolCallId = `call_audit_write_${suffix}`;
+ const deniedToolCallId = `call_audit_denied_${suffix}`;
+
+ const read = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: {
+ toolName: "mnote.page.get",
+ workspaceId: target.workspaceId,
+ documentId: target.documentId,
+ actorId: "smoke-user",
+ sessionId: `mnote_audit_${suffix}`,
+ runId: `run_audit_read_${suffix}`,
+ toolCallId: readToolCallId,
+ traceId: readTraceId,
+ capabilityScope: ["page.read"],
+ },
+ });
+ assert.equal(read.audit.effect, "read", "读工具 audit effect 应为 read");
+
+ const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: {
+ toolName: "mnote.page.save",
+ workspaceId: target.workspaceId,
+ documentId: target.documentId,
+ actorId: "smoke-user",
+ sessionId: `mnote_audit_${suffix}`,
+ runId: `run_audit_write_${suffix}`,
+ toolCallId: writeToolCallId,
+ traceId: writeTraceId,
+ idempotencyKey: `idem_audit_write_${suffix}`,
+ dryRun: false,
+ capabilityScope: ["page.write"],
+ args: {
+ mode: "replace",
+ content: [
+ {
+ id: `audit_block_${suffix}`,
+ type: "paragraph",
+ content: [{ type: "text", text: marker }],
+ },
+ ],
+ },
+ },
+ });
+ assert.equal(write.audit.effect, "write", "写工具 audit effect 应为 write");
+ assert(write.audit.commandId, "写工具 audit 必须包含 commandId");
+
+ const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-mnote-actor-id": "smoke-user",
+ "x-mnote-workspace-id": "ws_denied",
+ },
+ data: JSON.stringify({
+ toolName: "mnote.page.save",
+ workspaceId: target.workspaceId,
+ documentId: target.documentId,
+ actorId: "smoke-user",
+ sessionId: `mnote_audit_${suffix}`,
+ runId: `run_audit_denied_${suffix}`,
+ toolCallId: deniedToolCallId,
+ traceId: deniedTraceId,
+ idempotencyKey: `idem_audit_denied_${suffix}`,
+ dryRun: false,
+ capabilityScope: ["page.write"],
+ args: {
+ mode: "replace",
+ content: [
+ {
+ id: `audit_denied_block_${suffix}`,
+ type: "paragraph",
+ content: [{ type: "text", text: marker }],
+ },
+ ],
+ },
+ }),
+ });
+ const deniedText = await deniedResponse.text();
+ assert.equal(deniedResponse.status(), 403, "权限失败 smoke 应返回 workspace_context_conflict");
+ assert(!deniedText.includes(title), "权限失败响应不应包含页面标题");
+ assert(!deniedText.includes(marker), "权限失败响应不应包含正文 marker");
+
+ const readAudit = await requestJson(
+ context.request,
+ `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(readTraceId)}`,
+ {
+ method: "GET",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ },
+ );
+ const writeAudit = await requestJson(
+ context.request,
+ `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}`,
+ {
+ method: "GET",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ },
+ );
+ const writePersistedAudit = await requestJson(
+ context.request,
+ `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(writeTraceId)}&persistedOnly=true`,
+ {
+ method: "GET",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ },
+ );
+ const deniedPersistedAudit = await requestJson(
+ context.request,
+ `/api/hermes/tools/mnote/audit?traceId=${encodeURIComponent(deniedTraceId)}&persistedOnly=true`,
+ {
+ method: "GET",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ },
+ );
+
+ const readPhases = new Set((readAudit.events || []).map((event) => event.phase));
+ const writePhases = new Set((writeAudit.events || []).map((event) => event.phase));
+ const writePersistedPhases = new Set((writePersistedAudit.events || []).map((event) => event.phase));
+ const deniedPersistedPhases = new Set((deniedPersistedAudit.events || []).map((event) => event.phase));
+ assert(readPhases.has("started"), "读工具 audit 缺少 started");
+ assert(readPhases.has("completed"), "读工具 audit 缺少 completed");
+ assert(writePhases.has("started"), "写工具 audit 缺少 started");
+ assert(writePhases.has("completed"), "写工具 audit 缺少 completed");
+ assert(writePersistedPhases.has("started"), "持久化写工具 audit 缺少 started");
+ assert(writePersistedPhases.has("completed"), "持久化写工具 audit 缺少 completed");
+ assert(deniedPersistedPhases.has("failed"), "持久化权限失败 audit 缺少 failed");
+ assert(
+ (writeAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId),
+ "写工具 audit 查询结果未串到 Rust commandId",
+ );
+ assert(
+ (writePersistedAudit.events || []).some((event) => event.audit?.commandId === write.audit.commandId),
+ "持久化写工具 audit 查询结果未串到 Rust commandId",
+ );
+ assert(!JSON.stringify(writeAudit).includes(marker), "audit 查询结果不应包含完整正文内容");
+ assert(!JSON.stringify(writePersistedAudit).includes(marker), "持久化 audit 不应包含完整正文内容");
+ assert(!JSON.stringify(deniedPersistedAudit).includes(marker), "权限失败持久化 audit 不应包含完整正文内容");
+ assert(!JSON.stringify(deniedPersistedAudit).includes(title), "权限失败持久化 audit 不应包含页面标题");
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ documentId: target.documentId,
+ workspaceId: target.workspaceId,
+ readTraceId,
+ writeTraceId,
+ deniedTraceId,
+ writeCommandId: write.audit.commandId,
+ readPhases: Array.from(readPhases),
+ writePhases: Array.from(writePhases),
+ writePersistedPhases: Array.from(writePersistedPhases),
+ deniedPersistedPhases: Array.from(deniedPersistedPhases),
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await cleanupDocuments(context.request, createdIds).catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task-hermes-page-ai-baseline-smoke.js b/scripts/task-hermes-page-ai-baseline-smoke.js
new file mode 100644
index 00000000..f6a84e7b
--- /dev/null
+++ b/scripts/task-hermes-page-ai-baseline-smoke.js
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+const { chromium } = require("playwright");
+const {
+ BASE_URL,
+ UI_TIMEOUT_MS,
+ cleanupDocuments,
+ createTempDocument,
+ ensureAuthenticated,
+ renameDocument,
+} = require("./tree-shell-smoke-helpers");
+
+async function main() {
+ const suffix = Date.now().toString(36);
+ const title = `TEST-HERMES-AI-baseline-${suffix}`;
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
+ const page = await context.newPage();
+ const captured = [];
+ const createdIds = [];
+
+ page.on("request", (request) => {
+ const url = request.url();
+ if (url.includes("/api/ai-agent/run") || url.includes("/api/hermes/client/")) {
+ captured.push({
+ phase: "request",
+ method: request.method(),
+ url,
+ postData: request.postData() || "",
+ });
+ }
+ });
+ page.on("response", async (response) => {
+ const url = response.url();
+ if (!url.includes("/api/ai-agent/run") && !url.includes("/api/hermes/client/")) {
+ return;
+ }
+ let body = "";
+ try {
+ body = (await response.text()).slice(0, 1600);
+ } catch {
+ body = "
";
+ }
+ captured.push({
+ phase: "response",
+ status: response.status(),
+ url,
+ body,
+ headers: response.headers(),
+ });
+ });
+
+ try {
+ await ensureAuthenticated(page, context.request);
+ const target = await createTempDocument(context.request);
+ createdIds.push(target.documentId);
+ await renameDocument(context.request, target.workspaceId, target.documentId, title);
+
+ await page.goto(
+ `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
+ { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
+ );
+ await page.getByTestId("wolai-floating-ai").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.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').length > 0,
+ null,
+ { timeout: UI_TIMEOUT_MS },
+ );
+
+ const aiRequests = captured.filter((entry) => entry.phase === "request");
+ assert(aiRequests.length > 0, "未捕获页面 AI 主请求");
+ const firstRequest = aiRequests[0];
+ const legacyRunHit = aiRequests.some((entry) => entry.url.includes("/api/ai-agent/run"));
+ const hermesClientHit = aiRequests.some((entry) => entry.url.includes("/api/hermes/client/"));
+ const providerHermes502 = captured.some(
+ (entry) =>
+ entry.phase === "response" &&
+ entry.status === 502 &&
+ /provider|ai_provider_bridge_unavailable|hermes/i.test(entry.body || ""),
+ );
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ title,
+ documentId: target.documentId,
+ workspaceId: target.workspaceId,
+ firstRequestUrl: firstRequest.url,
+ legacyRunHit,
+ hermesClientHit,
+ providerHermes502,
+ captured,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await cleanupDocuments(context.request, createdIds).catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task-hermes-page-ai-mobile-smoke.js b/scripts/task-hermes-page-ai-mobile-smoke.js
new file mode 100644
index 00000000..d15f1502
--- /dev/null
+++ b/scripts/task-hermes-page-ai-mobile-smoke.js
@@ -0,0 +1,144 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+const { chromium } = require("playwright");
+const {
+ BASE_URL,
+ UI_TIMEOUT_MS,
+ cleanupDocuments,
+ createTempDocument,
+ ensureAuthenticated,
+ renameDocument,
+} = require("./tree-shell-smoke-helpers");
+
+async function main() {
+ const suffix = Date.now().toString(36);
+ const title = `TEST-HERMES-AI-mobile-${suffix}`;
+ const sessionId = `mnote_mobile_${suffix}`;
+ const runId = `run_mobile_${suffix}`;
+ const createdIds = [];
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: { width: 390, height: 844 },
+ });
+ const page = await context.newPage();
+
+ try {
+ await page.route("**/api/ai-agent/run", async (route) => {
+ throw new Error(`移动端页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
+ });
+ await page.route("**/api/hermes/client/sessions", async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId,
+ title: "移动端问答",
+ traceId: "trace_mobile",
+ persistence: "hermes_on_first_run",
+ }),
+ });
+ });
+ await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId,
+ traceId: "trace_mobile_restore",
+ session: {
+ sessionId,
+ messages: [{ role: "assistant", content: "Mobile restored" }],
+ },
+ }),
+ });
+ });
+ await page.route("**/api/hermes/client/runs", async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId,
+ runId,
+ events: [],
+ traceId: "trace_mobile",
+ }),
+ });
+ });
+ await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "text/event-stream; charset=utf-8" },
+ body:
+ `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Mobile " })}\n\n` +
+ `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Mobile response" })}\n\n`,
+ });
+ });
+
+ await ensureAuthenticated(page, context.request);
+ const target = await createTempDocument(context.request);
+ createdIds.push(target.documentId);
+ await renameDocument(context.request, target.workspaceId, target.documentId, title);
+ await page.goto(
+ `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
+ { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
+ );
+
+ await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
+ const metrics = await page.locator('[data-testid="wolai-page-ai-drawer"]').evaluate((drawer) => {
+ const panel = drawer.querySelector(".wolai-page-ai-panel");
+ const drawerRect = drawer.getBoundingClientRect();
+ const panelRect = panel ? panel.getBoundingClientRect() : drawerRect;
+ return {
+ viewportWidth: window.innerWidth,
+ documentWidth: document.documentElement.scrollWidth,
+ drawerLeft: drawerRect.left,
+ drawerRight: drawerRect.right,
+ panelLeft: panelRect.left,
+ panelRight: panelRect.right,
+ };
+ });
+ assert(metrics.drawerLeft >= 0, `drawer 左侧溢出: ${JSON.stringify(metrics)}`);
+ assert(metrics.drawerRight <= metrics.viewportWidth + 1, `drawer 右侧溢出: ${JSON.stringify(metrics)}`);
+ assert(metrics.panelLeft >= 0, `panel 左侧溢出: ${JSON.stringify(metrics)}`);
+ assert(metrics.panelRight <= metrics.viewportWidth + 1, `panel 右侧溢出: ${JSON.stringify(metrics)}`);
+ assert(metrics.documentWidth <= metrics.viewportWidth + 1, `页面出现横向滚动: ${JSON.stringify(metrics)}`);
+
+ await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { 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("Mobile response"),
+ null,
+ { timeout: UI_TIMEOUT_MS },
+ );
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ documentId: target.documentId,
+ workspaceId: target.workspaceId,
+ sessionId,
+ runId,
+ metrics,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await cleanupDocuments(context.request, createdIds).catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task-hermes-page-ai-retirement-guard.js b/scripts/task-hermes-page-ai-retirement-guard.js
new file mode 100644
index 00000000..85c63009
--- /dev/null
+++ b/scripts/task-hermes-page-ai-retirement-guard.js
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+
+const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
+const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
+
+async function fetchWithTimeout(path, init = {}) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+ try {
+ return await fetch(`${BASE_URL}${path}`, { ...init, signal: controller.signal });
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+async function main() {
+ const response = await fetchWithTimeout("/api/ai-agent/run", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ stream: true,
+ messages: [{ role: "user", content: "ping" }],
+ context: { documentId: "retirement_guard" },
+ options: { ai: { provider: "hermes" } },
+ }),
+ });
+ const text = await response.text();
+ let payload = null;
+ try {
+ payload = JSON.parse(text);
+ } catch {
+ payload = { raw: text };
+ }
+
+ assert(
+ response.status === 410,
+ `/api/ai-agent/run legacy guard 应返回退场状态,实际 ${response.status}: ${text.slice(0, 500)}`,
+ );
+ const code = response.headers.get("x-error-code") || payload.code || "";
+ const owner = response.headers.get("x-mnote-ai-execution-owner") || "";
+ assert(
+ code === "legacy_ai_agent_run_retired" && owner.includes("retired"),
+ `legacy guard 不应静默 fallback,code=${code}, owner=${owner}, body=${text.slice(0, 500)}`,
+ );
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ status: response.status,
+ code,
+ owner,
+ },
+ null,
+ 2,
+ ),
+ );
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task-hermes-page-ai-smoke.js b/scripts/task-hermes-page-ai-smoke.js
new file mode 100644
index 00000000..23d20e6c
--- /dev/null
+++ b/scripts/task-hermes-page-ai-smoke.js
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+const { chromium } = require("playwright");
+const {
+ BASE_URL,
+ UI_TIMEOUT_MS,
+ cleanupDocuments,
+ createTempDocument,
+ ensureAuthenticated,
+ renameDocument,
+} = require("./tree-shell-smoke-helpers");
+
+async function main() {
+ const suffix = Date.now().toString(36);
+ const title = `TEST-HERMES-AI-smoke-${suffix}`;
+ const sessionId = `mnote_smoke_${suffix}`;
+ const runId = `run_smoke_${suffix}`;
+ let sessionDetailHits = 0;
+ const captured = [];
+ const createdIds = [];
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
+ const page = await context.newPage();
+
+ try {
+ await page.route("**/api/ai-agent/run", async (route) => {
+ throw new Error(`页面 AI 不应请求旧 /api/ai-agent/run: ${route.request().url()}`);
+ });
+ await page.route("**/api/hermes/client/sessions", async (route) => {
+ captured.push({ kind: "session", method: route.request().method(), body: route.request().postData() || "" });
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId,
+ title: "当前页问答",
+ traceId: "trace_smoke",
+ persistence: "hermes_on_first_run",
+ }),
+ });
+ });
+ await page.route(`**/api/hermes/client/sessions/${sessionId}`, async (route) => {
+ sessionDetailHits += 1;
+ captured.push({ kind: "session-detail", method: route.request().method(), body: "" });
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId,
+ traceId: "trace_restore",
+ session: {
+ sessionId,
+ messages: [
+ { role: "user", content: `请总结 ${title}` },
+ { role: "tool", content: "mnote.page.get" },
+ { role: "assistant", content: "Smoke restored from Hermes session" },
+ ],
+ },
+ }),
+ });
+ });
+ await page.route("**/api/hermes/client/runs", async (route) => {
+ captured.push({ kind: "run", method: route.request().method(), body: route.request().postData() || "" });
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId,
+ runId,
+ events: [],
+ traceId: "trace_smoke",
+ }),
+ });
+ });
+ await page.route(`**/api/hermes/client/events/${runId}`, async (route) => {
+ captured.push({ kind: "events", method: route.request().method(), body: "" });
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "text/event-stream; charset=utf-8" },
+ body:
+ `data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` +
+ `data: ${JSON.stringify({ event: "tool.completed", run_id: runId, session_id: sessionId, name: "mnote.page.get" })}\n\n` +
+ `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "Smoke " })}\n\n` +
+ `data: ${JSON.stringify({ event: "message.delta", run_id: runId, session_id: sessionId, delta: "response" })}\n\n` +
+ `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "Smoke response" })}\n\n`,
+ });
+ });
+
+ await ensureAuthenticated(page, context.request);
+ const target = await createTempDocument(context.request);
+ createdIds.push(target.documentId);
+ await renameDocument(context.request, target.workspaceId, target.documentId, title);
+ await page.goto(
+ `${BASE_URL}/documents/${encodeURIComponent(target.documentId)}?workspaceId=${encodeURIComponent(target.workspaceId)}`,
+ { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS },
+ );
+
+ await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
+ await page.locator("[data-page-ai-input]").fill(`请总结 ${title}`, { 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("Smoke response"),
+ null,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ await page.waitForFunction(
+ () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"),
+ null,
+ { timeout: UI_TIMEOUT_MS },
+ );
+
+ const sessionRequest = captured.find((entry) => entry.kind === "session");
+ const runRequest = captured.find((entry) => entry.kind === "run");
+ const eventRequest = captured.find((entry) => entry.kind === "events");
+ assert(sessionRequest, "未捕获 /api/hermes/client/sessions 请求");
+ assert(runRequest, "未捕获 /api/hermes/client/runs 请求");
+ assert(eventRequest, "未捕获 /api/hermes/client/events 请求");
+ const runBody = JSON.parse(runRequest.body);
+ assert.equal(runBody.sessionId, sessionId, "run 请求必须携带 Hermes sessionId");
+ assert.equal(runBody.documentId, target.documentId, "run 请求必须携带 documentId");
+ assert(runBody.pageContext, "run 请求必须携带 pageContext");
+
+ await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
+ await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(
+ () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("Smoke restored from Hermes session"),
+ null,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ await page.waitForFunction(
+ () => (document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "").includes("mnote.page.get"),
+ null,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ assert(sessionDetailHits > 0, "刷新后必须从 Hermes session detail 恢复消息,而不是从 mnote 本地消息数组恢复");
+ const persisted = await page.evaluate(() => {
+ const keys = Object.keys(window.localStorage).filter((key) => key.startsWith("hermes_page_ai_session:"));
+ return keys.map((key) => ({ key, value: window.localStorage.getItem(key) }));
+ });
+ assert(
+ persisted.every((entry) => !entry.value || !entry.value.includes("Smoke response")),
+ "mnote localStorage 不应保存完整聊天消息内容",
+ );
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ documentId: target.documentId,
+ workspaceId: target.workspaceId,
+ sessionId,
+ runId,
+ capturedKinds: captured.map((entry) => entry.kind),
+ sessionDetailHits,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await cleanupDocuments(context.request, createdIds).catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task-hermes-page-ai-title-options-smoke.js b/scripts/task-hermes-page-ai-title-options-smoke.js
new file mode 100644
index 00000000..79bc7947
--- /dev/null
+++ b/scripts/task-hermes-page-ai-title-options-smoke.js
@@ -0,0 +1,239 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+const { chromium } = require("playwright");
+const {
+ BASE_URL,
+ cleanupDocuments,
+ createTempDocument,
+ ensureAuthenticated,
+ openDocument,
+ openSectionView,
+ renameDocument,
+ requestJson,
+ UI_TIMEOUT_MS,
+} = require("./tree-shell-smoke-helpers");
+
+async function main() {
+ const suffix = Date.now().toString(36);
+ const originalTitle = `TEST-HERMES-AI-title-original-${suffix}`;
+ const nextTitle = `TEST-HERMES-AI-title-updated-${suffix}`;
+ const createdIds = [];
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage();
+
+ try {
+ await ensureAuthenticated(page, context.request);
+ const target = await createTempDocument(context.request);
+ createdIds.push(target.documentId);
+ await renameDocument(context.request, target.workspaceId, target.documentId, originalTitle);
+
+ const common = {
+ workspaceId: target.workspaceId,
+ documentId: target.documentId,
+ actorId: "smoke-user",
+ sessionId: `mnote_smoke_title_${suffix}`,
+ runId: `run_smoke_title_${suffix}`,
+ traceId: `trace_smoke_title_${suffix}`,
+ capabilityScope: ["page.write"],
+ dryRun: false,
+ };
+
+ const dryRunTitle = `TEST-HERMES-AI-title-dry-${suffix}`;
+ const titleDryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: {
+ ...common,
+ dryRun: true,
+ toolName: "mnote.page.update_title",
+ toolCallId: `call_title_dry_${suffix}`,
+ idempotencyKey: `idem_title_dry_${suffix}`,
+ args: { title: dryRunTitle },
+ },
+ });
+ assert.equal(titleDryRun.result.dryRun, true, "标题 dryRun 不应写入");
+ const metaAfterDryRun = await requestJson(
+ context.request,
+ `/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
+ { method: "GET" },
+ );
+ assert(!JSON.stringify(metaAfterDryRun).includes(dryRunTitle), "标题 dryRun 后 meta 不应变化");
+
+ const titleResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: {
+ ...common,
+ toolName: "mnote.page.update_title",
+ toolCallId: `call_title_${suffix}`,
+ idempotencyKey: `idem_title_${suffix}`,
+ args: { title: nextTitle },
+ },
+ });
+ assert.equal(titleResult.result.commandName, "page.head.updateTitle", "标题必须走 page.head.updateTitle");
+ assert.equal(titleResult.audit.commandId, titleResult.result.commandId, "标题 audit commandId 必须指向 Rust commandId");
+ const titleRetry = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: {
+ ...common,
+ toolName: "mnote.page.update_title",
+ toolCallId: `call_title_${suffix}`,
+ idempotencyKey: `idem_title_${suffix}`,
+ args: { title: nextTitle },
+ },
+ });
+ assert.equal(
+ titleRetry.result.commandId,
+ titleResult.result.commandId,
+ "标题同一 idempotencyKey 重试必须返回同一个 commandId",
+ );
+
+ const optionsResult = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: {
+ ...common,
+ toolName: "mnote.page.update_options",
+ toolCallId: `call_options_${suffix}`,
+ idempotencyKey: `idem_options_${suffix}`,
+ args: { options: { wideLayout: true, smallText: true, pageFont: "serif" } },
+ },
+ });
+ assert.equal(
+ optionsResult.result.commandName,
+ "page.layout.updateOptions",
+ "页面设置必须走 page.layout.updateOptions",
+ );
+ assert.equal(
+ optionsResult.audit.commandId,
+ optionsResult.result.commandId,
+ "页面设置 audit commandId 必须指向 Rust commandId",
+ );
+ assert(
+ Array.isArray(optionsResult.result.ignoredOptions) &&
+ optionsResult.result.ignoredOptions.includes("pageFont"),
+ "planned/ui_only 页面设置字段必须明确返回 ignoredOptions",
+ );
+ assert(
+ Array.isArray(optionsResult.result.warnings) &&
+ optionsResult.result.warnings.some((warning) => warning && warning.code === "page_option_not_wired"),
+ "planned/ui_only 页面设置字段必须明确返回 warning",
+ );
+
+ const meta = await requestJson(
+ context.request,
+ `/api/documents/meta?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
+ { method: "GET" },
+ );
+ assert(JSON.stringify(meta).includes(nextTitle), "meta 未读到 AI 更新后的标题");
+ assert(JSON.stringify(meta).includes("wide"), "meta 未读到页面设置更新结果");
+
+ await openDocument(page, target.workspaceId, target.documentId);
+ await openSectionView(page);
+ await page.waitForFunction(
+ (title) => {
+ const input = document.querySelector('[data-page-title-input="true"]');
+ const current = document.querySelector('[data-page-title-current="true"]');
+ return (input && input.value === title) || (current && current.textContent.includes(title));
+ },
+ nextTitle,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ await page.waitForFunction(
+ ({ documentId, title }) => {
+ const selectors = [
+ `#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`,
+ `.tree-row[data-shell-mode="page"][data-node-id="${CSS.escape(documentId)}"] > .tree-link > .tree-link-title`,
+ ];
+ return selectors.some((selector) => {
+ const node = document.querySelector(selector);
+ return node && (node.textContent || "").includes(title);
+ });
+ },
+ { documentId: target.documentId, title: nextTitle },
+ { timeout: UI_TIMEOUT_MS },
+ );
+ const uiState = await page.evaluate((documentId) => {
+ const shell = document.querySelector(".document-shell");
+ const sessionKey = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || "";
+ return {
+ titleInput: document.querySelector('[data-page-title-input="true"]')?.value || "",
+ currentTitle: document.querySelector('[data-page-title-current="true"]')?.textContent || "",
+ sidebarTitle:
+ document.querySelector(`#sidebar-tree-root .tree-row[data-node-id="${CSS.escape(documentId)}"] .tree-link-title`)
+ ?.textContent || "",
+ wideLayout: shell?.getAttribute("data-page-wide-layout") || "",
+ smallText: shell?.getAttribute("data-page-small-text") || "",
+ pageAiSessionOwner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "",
+ pageAiSessionKey: sessionKey,
+ pageAiSessionStorage: sessionKey && window.localStorage ? window.localStorage.getItem(sessionKey) || "" : "",
+ };
+ }, target.documentId);
+ assert(
+ uiState.titleInput === nextTitle || uiState.currentTitle.includes(nextTitle),
+ `刷新后页面 UI 未显示 AI 更新标题: ${JSON.stringify(uiState)}`,
+ );
+ assert(uiState.sidebarTitle.includes(nextTitle), `sidebar 未显示 AI 更新标题: ${JSON.stringify(uiState)}`);
+ assert.equal(uiState.wideLayout, "true", "刷新后 document-shell 未应用 wideLayout");
+ assert.equal(uiState.smallText, "true", "刷新后 document-shell 未应用 smallText");
+
+ await page.locator('[data-testid="wolai-floating-ai"]').click({ timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(
+ () =>
+ document.documentElement.getAttribute("data-mnote-page-ai-session-owner") === "hermes" &&
+ Boolean(document.documentElement.getAttribute("data-mnote-page-ai-session-key")),
+ undefined,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ const aiSessionState = await page.evaluate(() => {
+ const key = document.documentElement.getAttribute("data-mnote-page-ai-session-key") || "";
+ return {
+ owner: document.documentElement.getAttribute("data-mnote-page-ai-session-owner") || "",
+ key,
+ stored: key && window.localStorage ? window.localStorage.getItem(key) || "" : "",
+ pageTitle:
+ document.querySelector('[data-page-title-input="true"]')?.value ||
+ document.querySelector('[data-page-title-current="true"]')?.textContent ||
+ "",
+ };
+ });
+ assert.equal(aiSessionState.owner, "hermes", "页面 AI session owner 必须是 Hermes");
+ const storedSessionState = JSON.parse(aiSessionState.stored || "{}");
+ assert(storedSessionState.activeSessionId, "mnote 本地只应保存 Hermes activeSessionId");
+ assert(!aiSessionState.stored.includes(nextTitle), "mnote 本地 AI session 状态不应保存或驱动页面标题真相");
+ assert(aiSessionState.pageTitle.includes(nextTitle), "页面标题真相必须仍来自页面 UI / Page Aggregate");
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ documentId: target.documentId,
+ workspaceId: target.workspaceId,
+ title: nextTitle,
+ titleCommand: titleResult.result.commandName,
+ optionsCommand: optionsResult.result.commandName,
+ ignoredOptions: optionsResult.result.ignoredOptions,
+ warningCodes: optionsResult.result.warnings.map((warning) => warning.code),
+ uiState,
+ aiSessionState,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await cleanupDocuments(context.request, createdIds).catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task-hermes-page-ai-tool-smoke.js b/scripts/task-hermes-page-ai-tool-smoke.js
new file mode 100644
index 00000000..f0d01f2c
--- /dev/null
+++ b/scripts/task-hermes-page-ai-tool-smoke.js
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+const { chromium } = require("playwright");
+const {
+ BASE_URL,
+ UI_TIMEOUT_MS,
+ cleanupDocuments,
+ createTempDocument,
+ ensureAuthenticated,
+ renameDocument,
+ requestJson,
+} = require("./tree-shell-smoke-helpers");
+
+async function main() {
+ const suffix = Date.now().toString(36);
+ const title = `TEST-HERMES-AI-tool-${suffix}`;
+ const createdIds = [];
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage();
+
+ try {
+ await ensureAuthenticated(page, context.request);
+ const target = await createTempDocument(context.request);
+ createdIds.push(target.documentId);
+ await renameDocument(context.request, target.workspaceId, target.documentId, title);
+
+ const manifest = await requestJson(context.request, "/api/hermes/tools/mnote/manifest", {
+ method: "GET",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ });
+ const tools = manifest?.manifest?.tools || [];
+ assert(tools.some((tool) => tool.name === "mnote.page.get"), "manifest 缺少 mnote.page.get");
+
+ const call = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: {
+ toolName: "mnote.page.get",
+ workspaceId: target.workspaceId,
+ documentId: target.documentId,
+ actorId: "smoke-user",
+ sessionId: `mnote_smoke_tool_${suffix}`,
+ runId: `run_smoke_tool_${suffix}`,
+ toolCallId: `call_smoke_tool_${suffix}`,
+ traceId: `trace_smoke_tool_${suffix}`,
+ capabilityScope: ["page.read"],
+ args: {
+ includeBody: true,
+ includeOptions: true,
+ includeBlocks: true,
+ },
+ },
+ });
+
+ assert.equal(call.ok, true, "tool call 应成功");
+ assert.equal(call.toolName, "mnote.page.get", "toolName 不一致");
+ assert.equal(call.toolCallId, `call_smoke_tool_${suffix}`, "toolCallId 不一致");
+ assert.equal(call.result.documentId, target.documentId, "工具结果 documentId 不一致");
+ assert.equal(call.result.workspaceId, target.workspaceId, "工具结果 workspaceId 不一致");
+ assert(call.result.title, "工具结果缺少标题");
+ assert(call.audit && call.audit.effect === "read", "工具结果缺少 read audit");
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ documentId: target.documentId,
+ workspaceId: target.workspaceId,
+ toolName: call.toolName,
+ title: call.result.title,
+ audit: call.audit,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await cleanupDocuments(context.request, createdIds).catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task-hermes-page-ai-writeback-smoke.js b/scripts/task-hermes-page-ai-writeback-smoke.js
new file mode 100644
index 00000000..02943ab1
--- /dev/null
+++ b/scripts/task-hermes-page-ai-writeback-smoke.js
@@ -0,0 +1,224 @@
+#!/usr/bin/env node
+"use strict";
+
+const assert = require("node:assert");
+const { chromium } = require("playwright");
+const {
+ BASE_URL,
+ UI_TIMEOUT_MS,
+ cleanupDocuments,
+ createTempDocument,
+ ensureAuthenticated,
+ openDocument,
+ renameDocument,
+ requestJson,
+} = require("./tree-shell-smoke-helpers");
+
+async function assertPageAiPermissionFailureUi(page, target, suffix) {
+ const sessionId = `mnote_smoke_permission_${suffix}`;
+ const runId = `run_smoke_permission_${suffix}`;
+ const sessionRoute = "**/api/hermes/client/sessions";
+ const sessionDetailRoute = `**/api/hermes/client/sessions/${sessionId}`;
+ const runsRoute = "**/api/hermes/client/runs";
+ const eventsRoute = `**/api/hermes/client/events/${runId}`;
+
+ await page.route(sessionRoute, async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId,
+ title: "权限失败 UI 验证",
+ traceId: `trace_permission_${suffix}`,
+ }),
+ });
+ });
+ await page.route(sessionDetailRoute, async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ sessionId,
+ session: { sessionId, messages: [] },
+ }),
+ });
+ });
+ await page.route(runsRoute, async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ok: true,
+ upstream: { run_id: runId, status: "started" },
+ traceId: `trace_permission_${suffix}`,
+ }),
+ });
+ });
+ await page.route(eventsRoute, async (route) => {
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "text/event-stream; charset=utf-8" },
+ body:
+ `data: ${JSON.stringify({ event: "tool.started", run_id: runId, session_id: sessionId, tool: "mnote.page.save" })}\n\n` +
+ `data: ${JSON.stringify({ event: "tool.failed", run_id: runId, session_id: sessionId, tool: "mnote.page.save", error: { code: "permission_denied" } })}\n\n` +
+ `data: ${JSON.stringify({ event: "run.completed", run_id: runId, session_id: sessionId, output: "permission_denied" })}\n\n`,
+ });
+ });
+
+ try {
+ await openDocument(page, target.workspaceId, target.documentId);
+ await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
+ await page.locator("[data-page-ai-input]").fill("权限失败 UI 验证", { timeout: UI_TIMEOUT_MS });
+ await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
+ await page.waitForFunction(
+ () => {
+ const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
+ return text.includes("mnote.page.save") && text.includes("permission_denied");
+ },
+ null,
+ { timeout: UI_TIMEOUT_MS },
+ );
+ } finally {
+ await page.unroute(sessionRoute).catch(() => undefined);
+ await page.unroute(sessionDetailRoute).catch(() => undefined);
+ await page.unroute(runsRoute).catch(() => undefined);
+ await page.unroute(eventsRoute).catch(() => undefined);
+ }
+}
+
+async function main() {
+ const suffix = Date.now().toString(36);
+ const title = `TEST-HERMES-AI-write-${suffix}`;
+ const marker = `TEST-HERMES-AI-WRITEBACK-${suffix}`;
+ const createdIds = [];
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await context.newPage();
+
+ try {
+ await ensureAuthenticated(page, context.request);
+ const target = await createTempDocument(context.request);
+ createdIds.push(target.documentId);
+ await renameDocument(context.request, target.workspaceId, target.documentId, title);
+
+ const baseTool = {
+ toolName: "mnote.page.save",
+ workspaceId: target.workspaceId,
+ documentId: target.documentId,
+ actorId: "smoke-user",
+ sessionId: `mnote_smoke_write_${suffix}`,
+ runId: `run_smoke_write_${suffix}`,
+ toolCallId: `call_smoke_write_${suffix}`,
+ traceId: `trace_smoke_write_${suffix}`,
+ idempotencyKey: `idem_smoke_write_${suffix}`,
+ capabilityScope: ["page.write"],
+ args: {
+ mode: "replace",
+ content: [
+ {
+ id: `block_${suffix}`,
+ type: "paragraph",
+ content: [{ type: "text", text: marker }],
+ },
+ ],
+ },
+ };
+
+ const deniedResponse = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-mnote-actor-id": "smoke-user",
+ "x-mnote-workspace-id": "ws_denied",
+ },
+ data: JSON.stringify({
+ ...baseTool,
+ traceId: `trace_smoke_write_denied_${suffix}`,
+ toolCallId: `call_smoke_write_denied_${suffix}`,
+ idempotencyKey: `idem_smoke_write_denied_${suffix}`,
+ }),
+ });
+ const deniedText = await deniedResponse.text();
+ assert.equal(deniedResponse.status(), 403, "workspace 上下文冲突应返回 403");
+ assert(deniedText.includes("workspace_context_conflict"), "权限失败应返回稳定 workspace_context_conflict");
+ assert(!deniedText.includes(title), "权限失败响应不应泄露页面标题");
+ assert(!deniedText.includes(marker), "权限失败响应不应泄露正文内容");
+ await assertPageAiPermissionFailureUi(page, target, suffix);
+
+ const dryRun = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: { ...baseTool, dryRun: true, idempotencyKey: `idem_smoke_write_dry_${suffix}` },
+ });
+ assert.equal(dryRun.result.dryRun, true, "dryRun 不应写入");
+ const dryRunContent = await requestJson(
+ context.request,
+ `/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
+ { method: "GET" },
+ );
+ assert(!JSON.stringify(dryRunContent).includes(marker), "dryRun 后不应读到 AI 写入标记");
+
+ await openDocument(page, target.workspaceId, target.documentId);
+ await page
+ .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]')
+ .waitFor({ state: "attached", timeout: UI_TIMEOUT_MS })
+ .catch(() => undefined);
+
+ const write = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: { ...baseTool, dryRun: false },
+ });
+ assert.equal(write.ok, true, "写入 tool call 应成功");
+ assert.equal(write.audit.effect, "write", "写入 audit effect 应为 write");
+ assert.equal(write.result.commandName, "page.body.save", "写入必须走 page.body.save");
+ assert(write.result.commandId, "写入结果必须包含 Rust commandId");
+ assert.equal(write.audit.commandId, write.result.commandId, "audit commandId 必须指向 Rust commandId");
+ await page.waitForFunction((expected) => (document.body.textContent || "").includes(expected), marker, {
+ timeout: UI_TIMEOUT_MS,
+ });
+
+ const retry = await requestJson(context.request, "/api/hermes/tools/mnote/call", {
+ method: "POST",
+ headers: { "x-mnote-actor-id": "smoke-user" },
+ data: { ...baseTool, dryRun: false },
+ });
+ assert.equal(retry.ok, true, "幂等重试 tool call 应成功");
+ assert.equal(retry.result.commandId, write.result.commandId, "同一 idempotencyKey 重试必须返回同一个 commandId");
+ assert.equal(retry.audit.commandId, write.audit.commandId, "同一 idempotencyKey 重试必须返回同一个 audit commandId");
+
+ const content = await requestJson(
+ context.request,
+ `/api/documents/content?documentId=${encodeURIComponent(target.documentId)}&workspaceId=${encodeURIComponent(target.workspaceId)}`,
+ { method: "GET" },
+ );
+ assert(JSON.stringify(content).includes(marker), "刷新读取内容后未找到 AI 写入标记");
+
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ baseUrl: BASE_URL,
+ documentId: target.documentId,
+ workspaceId: target.workspaceId,
+ marker,
+ commandName: write.result.commandName,
+ audit: write.audit,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await cleanupDocuments(context.request, createdIds).catch(() => undefined);
+ await context.close().catch(() => undefined);
+ await browser.close().catch(() => undefined);
+ }
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
+ process.exit(1);
+});
diff --git a/scripts/task052-ai-tools-runtime-smoke.js b/scripts/task052-ai-tools-runtime-smoke.js
index 447ab1a1..eb784121 100644
--- a/scripts/task052-ai-tools-runtime-smoke.js
+++ b/scripts/task052-ai-tools-runtime-smoke.js
@@ -1,5 +1,15 @@
"use strict";
+if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
+ console.log(JSON.stringify({
+ ok: true,
+ retired: true,
+ script: "task052-ai-tools-runtime-smoke.js",
+ reason: "旧 /api/ai-agent/run AI tools runtime smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
+ }, null, 2));
+ process.exit(0);
+}
+
const { chromium } = require("playwright");
const BASE_URL = process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000";
diff --git a/scripts/task111-phase7-document-ai-online-smoke.js b/scripts/task111-phase7-document-ai-online-smoke.js
index 43f916b4..ef273cf4 100644
--- a/scripts/task111-phase7-document-ai-online-smoke.js
+++ b/scripts/task111-phase7-document-ai-online-smoke.js
@@ -1,5 +1,15 @@
"use strict";
+if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
+ console.log(JSON.stringify({
+ ok: true,
+ retired: true,
+ script: "task111-phase7-document-ai-online-smoke.js",
+ reason: "旧 /api/ai-agent/run phase7 online smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
+ }, null, 2));
+ process.exit(0);
+}
+
const { chromium } = require("playwright");
const {
BASE_URL,
diff --git a/scripts/task155-e27-ai-edit-smoke.js b/scripts/task155-e27-ai-edit-smoke.js
index 3e00025e..9eb5aa12 100644
--- a/scripts/task155-e27-ai-edit-smoke.js
+++ b/scripts/task155-e27-ai-edit-smoke.js
@@ -1,6 +1,16 @@
#!/usr/bin/env node
"use strict";
+if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
+ console.log(JSON.stringify({
+ ok: true,
+ retired: true,
+ script: "task155-e27-ai-edit-smoke.js",
+ reason: "旧 /api/ai-agent/run E27 AI 编辑 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
+ }, null, 2));
+ process.exit(0);
+}
+
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
diff --git a/scripts/task156-e27-ai-writeback-smoke.js b/scripts/task156-e27-ai-writeback-smoke.js
index de3cb079..98e74fa7 100644
--- a/scripts/task156-e27-ai-writeback-smoke.js
+++ b/scripts/task156-e27-ai-writeback-smoke.js
@@ -1,6 +1,16 @@
#!/usr/bin/env node
"use strict";
+if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
+ console.log(JSON.stringify({
+ ok: true,
+ retired: true,
+ script: "task156-e27-ai-writeback-smoke.js",
+ reason: "旧 /api/ai-agent/run E27 AI 写回 smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
+ }, null, 2));
+ process.exit(0);
+}
+
const assert = require("node:assert");
const fs = require("node:fs");
const { chromium } = require("playwright");
diff --git a/scripts/task178-page-ai-local-subtree-context-smoke.js b/scripts/task178-page-ai-local-subtree-context-smoke.js
index 047eb581..e9a76cba 100644
--- a/scripts/task178-page-ai-local-subtree-context-smoke.js
+++ b/scripts/task178-page-ai-local-subtree-context-smoke.js
@@ -1,6 +1,16 @@
#!/usr/bin/env node
"use strict";
+if (process.env.MNOTE_ALLOW_RETIRED_AI_AGENT_RUN_SMOKE !== "1") {
+ console.log(JSON.stringify({
+ ok: true,
+ retired: true,
+ script: "task178-page-ai-local-subtree-context-smoke.js",
+ reason: "旧 /api/ai-agent/run 页面 AI context smoke 已退役;当前请运行 scripts/task-hermes-page-ai-retirement-guard.js 验证 legacy guard。",
+ }, null, 2));
+ process.exit(0);
+}
+
const assert = require("node:assert");
const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");