feat(ai): switch page ai to hermes panel
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user