"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";
const REQUEST_TIMEOUT_MS = 120_000;
const UI_TIMEOUT_MS = 30_000;
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
async function requestJsonWithCookieHeader(path, init = {}, cookieHeader = "") {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
...(init.data !== undefined ? { "content-type": "application/json" } : {}),
...(init.headers || {}),
...(cookieHeader ? { cookie: cookieHeader } : {}),
},
body: init.data !== undefined ? JSON.stringify(init.data) : init.body,
signal: controller.signal,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok) {
throw new Error(
`${path} 请求失败: ${response.status} ${response.statusText} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`);
}
return payload;
} finally {
clearTimeout(timer);
}
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function requestJson(requestContext, path, init = {}) {
const response = await requestContext.fetch(`${BASE_URL}${path}`, {
...init,
headers:
init.data !== undefined
? {
"content-type": "application/json",
...(init.headers || {}),
}
: {
...(init.headers || {}),
},
timeout: REQUEST_TIMEOUT_MS,
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = text;
}
if (!response.ok()) {
throw new Error(
`${path} 请求失败: ${response.status()} ${response.statusText()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`,
);
}
const contentType = response.headers()["content-type"] || "";
if (!contentType.includes("application/json")) {
throw new Error(`${path} 返回了非 JSON 内容:${String(text).slice(0, 200)}`);
}
return payload;
}
async function ensureAuthenticated(page, requestContext) {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const tryWhoAmI = async () => {
try {
return await requestJson(requestContext, "/api/auth/whoami", { method: "GET" });
} catch {
return null;
}
};
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
let viewer = await tryWhoAmI();
if (viewer) return viewer;
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
for (let i = 0; i < 20; i += 1) {
await sleep(500);
viewer = await tryWhoAmI();
if (viewer) return viewer;
}
throw new Error("测试账号快速登录后仍无法获取 whoami");
}
async function createTempDocument(requestContext) {
const payload = await requestJson(requestContext, "/api/documents/create", {
method: "POST",
data: { parentId: null },
});
assert(payload && typeof payload.id === "string", "创建临时页面失败:缺少 id");
assert(payload && typeof payload.workspace_id === "string", "创建临时页面失败:缺少 workspace_id");
return { documentId: payload.id, workspaceId: payload.workspace_id };
}
async function renameDocument(requestContext, documentId, workspaceId, title) {
return await requestJson(requestContext, "/api/documents/title", {
method: "POST",
data: { documentId, workspaceId, title, commandName: "page.head.updateTitle" },
});
}
async function saveDocument(requestContext, documentId, workspaceId, content) {
return await requestJson(requestContext, "/api/documents/save", {
method: "POST",
data: { documentId, workspaceId, content },
});
}
async function purgeTempDocument(requestContext, documentId) {
return await requestJson(requestContext, "/api/documents/purge", {
method: "POST",
data: { documentId },
});
}
async function runAiAgentDocsSmoke(requestContext, uniqueTitle, needleText, cookieHeader) {
const payload = await requestJsonWithCookieHeader("/api/ai-agent/run", {
method: "POST",
data: {
stream: false,
maxSteps: 4,
scope: "document",
messages: [
{
role: "user",
content: `{"query":"${uniqueTitle}","limit":5}`,
},
],
toolChoice: {
mode: "manual",
toolSets: ["toolset.docs_read"],
tools: ["docs_search", "docs_read"],
},
context: {
documentId: "smoke-doc-context",
documentBlocks: [],
},
options: {
ai: {
provider: "codex",
sessionId: "",
},
},
},
}, cookieHeader);
assert(Array.isArray(payload.events), "AI Agent 返回缺少 events");
const toolResults = payload.events.filter((event) => event && event.type === "tool_result");
assert(toolResults.length >= 1, "AI Agent 未返回任何 tool_result");
const searchResultEvent = toolResults.find((event) => event.data && event.data.tool === "docs_search" && event.data.ok === true);
assert(searchResultEvent, "docs_search 未成功执行");
const searchResults = searchResultEvent.data.result && Array.isArray(searchResultEvent.data.result.results)
? searchResultEvent.data.result.results
: [];
assert(searchResults.length >= 1, "docs_search 没有返回结果");
const top = searchResults[0];
assert(typeof top.id === "string" && top.id, "docs_search 首条结果缺少 documentId");
const readPayload = await requestJsonWithCookieHeader("/api/ai-agent/run", {
method: "POST",
data: {
stream: false,
maxSteps: 4,
scope: "document",
messages: [
{
role: "user",
content: `{"documentId":"${top.id}","maxChars":2500,"includeContent":false}`,
},
],
toolChoice: {
mode: "manual",
toolSets: ["toolset.docs_read"],
tools: ["docs_read"],
},
context: {
documentId: "smoke-doc-context",
documentBlocks: [],
},
options: {
ai: {
provider: "codex",
sessionId: "",
},
},
},
}, cookieHeader);
assert(Array.isArray(readPayload.events), "docs_read 返回缺少 events");
const readEvent = readPayload.events.find((event) => event && event.type === "tool_result" && event.data && event.data.tool === "docs_read" && event.data.ok === true);
assert(readEvent, "docs_read 未成功执行");
const rawText = String(readEvent.data.result?.rawText ?? "");
assert(rawText.includes(needleText), `docs_read 返回未命中预期正文片段:${needleText}`);
return {
searchDocumentId: top.id,
searchResultsCount: searchResults.length,
readRawTextLength: Number(readEvent.data.result?.rawTextLength ?? 0),
};
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
let tempDocument = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
tempDocument = await createTempDocument(context.request);
const uniqueSuffix = `${Date.now()}`;
const uniqueTitle = `task052-ai-runtime-${uniqueSuffix}`;
const needleText = `task052 Rust docs runtime smoke ${uniqueSuffix}`;
await renameDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, uniqueTitle);
await saveDocument(context.request, tempDocument.documentId, tempDocument.workspaceId, [
{
id: `task052_block_${uniqueSuffix}`,
type: "paragraph",
props: {},
content: [{ type: "text", text: needleText }],
children: [],
},
]);
const cookies = await context.cookies(BASE_URL);
const cookieHeader = cookies.map((item) => `${item.name}=${item.value}`).join("; ");
const runtime = await runAiAgentDocsSmoke(context.request, uniqueTitle, needleText, cookieHeader);
console.log(JSON.stringify({
ok: true,
workspaceId: tempDocument.workspaceId,
documentId: tempDocument.documentId,
title: uniqueTitle,
needleText,
...runtime,
}, null, 2));
} catch (error) {
caughtError = error;
} finally {
if (tempDocument?.documentId) {
try {
await purgeTempDocument(context.request, tempDocument.documentId);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(`清理临时页面失败:${cleanupError instanceof Error ? cleanupError.stack || cleanupError.message : String(cleanupError)}`);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});