chore: 保存当前架构收口与 bug 修复快照

归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
lix-2026
2026-05-18 17:01:35 +08:00
parent 61ee4a38a2
commit a2cb1338c8
953 changed files with 14383 additions and 211845 deletions
+112 -8
View File
@@ -22,6 +22,7 @@
import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
import { readFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
@@ -33,6 +34,97 @@ function debugLog(message) {
if (DEBUG) process.stderr.write(`${message}\n`);
}
const toolContextStorage = new AsyncLocalStorage();
function isWriteMnoteTool(toolName) {
return !['mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch'].includes(toolName);
}
function stableIdPart(value, fallback) {
const text = String(value || fallback || '')
.trim()
.replace(/[^a-zA-Z0-9_.:-]+/g, '_')
.slice(0, 80);
return text || String(fallback || 'unknown');
}
function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
const {
actorId,
sessionId,
runId,
toolCallId,
traceId,
idempotencyKey,
dryRun,
workspaceId,
...args
} = rawArgs || {};
const effectiveSessionId =
sessionId || context.sessionId || context.acpSessionId || `reasonix_acp_session_${randomUUID()}`;
const effectiveRunId = runId || context.runId || effectiveSessionId;
const effectiveToolCallId =
toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`;
const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`;
const writeTool = isWriteMnoteTool(toolName);
const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false;
const effectiveIdempotencyKey =
idempotencyKey ||
`idem_${stableIdPart(toolName, 'tool')}_${stableIdPart(effectiveRunId, 'run')}_${stableIdPart(effectiveToolCallId, 'call')}`;
return {
toolName,
args,
workspaceId: workspaceId || context.workspaceId || 'default',
documentId: args.documentId || context.documentId,
actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
sessionId: effectiveSessionId,
runId: effectiveRunId,
toolCallId: effectiveToolCallId,
traceId: effectiveTraceId,
dryRun: effectiveDryRun,
idempotencyKey: effectiveIdempotencyKey,
};
}
if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
const payload = buildMnoteToolPayload(
'mnote.doc.markdown_edit',
{
workspaceId: 'ws_demo',
documentId: 'doc_1',
operations: [{ search: '旧', replace: '新' }],
},
{
actorId: 'user_1',
sessionId: 'sess_1',
runId: 'run_1',
traceId: 'trace_1',
},
);
const required = [
'toolName',
'workspaceId',
'documentId',
'actorId',
'sessionId',
'runId',
'toolCallId',
'traceId',
'dryRun',
'idempotencyKey',
];
for (const key of required) {
if (payload[key] === undefined || payload[key] === null || payload[key] === '') {
throw new Error(`selftest missing ${key}`);
}
}
if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) {
throw new Error('selftest expected identity fields outside args');
}
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
// 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。
function loadApiKey() {
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
@@ -229,14 +321,15 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
async function callMnoteTool(toolName, args) {
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName,
args,
workspaceId: args.workspaceId || 'default',
}),
headers: {
'Content-Type': 'application/json',
'x-mnote-actor-id': payload.actorId,
'x-mnote-workspace-id': payload.workspaceId,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
@@ -357,6 +450,15 @@ onRequest('session/prompt', async (params) => {
}
session.aborter = new AbortController();
const toolContext = {
acpSessionId: params.sessionId,
sessionId: params.mnoteSessionId || params.sessionId,
runId: params.runId || params.mnoteRunId || params.sessionId,
actorId: params.actorId || params.mnoteActorId,
traceId: params.traceId || params.mnoteTraceId,
workspaceId: params.workspaceId,
documentId: params.documentId,
};
let stopReason = 'end_turn';
let hasAssistantOutput = false;
let hasToolCall = false;
@@ -386,7 +488,8 @@ onRequest('session/prompt', async (params) => {
}
try {
for await (const ev of session.loop.step(text)) {
await toolContextStorage.run(toolContext, async () => {
for await (const ev of session.loop.step(text)) {
if (session.aborter?.signal.aborted) {
stopReason = 'cancelled';
break;
@@ -480,7 +583,8 @@ onRequest('session/prompt', async (params) => {
if (ev.stats) {
emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
}
}
}
});
} catch (err) {
const message = err.message || String(err);
const stack = err.stack || '';
+10 -1
View File
@@ -1,9 +1,18 @@
#!/usr/bin/env node
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
process.env.CONVEX_TMPDIR = "/mnt/Data1T/mnote/.convex-tmp";
const adminKey = "mnote-local|01cedce68c51e168c6aacb282a90f7d233f56eddfabb07944a1d9dd9506f73ba888fc7daff";
const repoRoot = "/mnt/Data1T/mnote";
const rootConvexDir = path.join(repoRoot, "convex");
const recycleConvexDir = path.join(repoRoot, "recycle", "wolai-frontend", "convex");
const rootHasFullSchema = fs.existsSync(path.join(rootConvexDir, "pages.ts"));
const deployCwd = rootHasFullSchema
? repoRoot
: path.dirname(recycleConvexDir);
const args = [
"deploy",
@@ -14,7 +23,7 @@ const args = [
];
const child = spawn("npx", ["convex", ...args], {
cwd: "/mnt/Data1T/mnote/wolai-frontend",
cwd: deployCwd,
stdio: "inherit",
env: { ...process.env, CONVEX_TMPDIR: "/mnt/Data1T/mnote/.convex-tmp" },
});
@@ -16,6 +16,15 @@ const {
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-aggregate-refresh-persistence-smoke");
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
async function callMnoteTool(requestContext, payload) {
return requestJson(requestContext, "/api/hermes/tools/mnote/call", {
method: "POST",
headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
data: payload,
});
}
async function fetchPageAggregate(requestContext, workspaceId, documentId) {
const payload = await requestJson(
@@ -27,6 +36,37 @@ async function fetchPageAggregate(requestContext, workspaceId, documentId) {
return payload.result;
}
function pageSubtreeSummary(aggregate) {
const subtree = aggregate?.tree?.pageSubtree ?? null;
return {
rootNodeId: subtree?.rootNodeId ?? null,
outlineLength: Array.isArray(subtree?.outline) ? subtree.outline.length : null,
};
}
async function fetchAiDoc(requestContext, target, actorId, suffix, label) {
const response = await callMnoteTool(requestContext, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_page_aggregate_ai_fetch_${suffix}`,
runId: `run_page_aggregate_ai_fetch_${suffix}_${label}`,
toolCallId: `call_page_aggregate_ai_fetch_${suffix}_${label}`,
traceId: `trace_page_aggregate_ai_fetch_${suffix}_${label}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 20,
},
});
assert.equal(response.ok, true, `${label}: mnote.doc.fetch 应成功`);
assert.equal(response.result.schema, "mnote.page_ai_context.v1", `${label}: AI fetch schema 应稳定`);
assert(Array.isArray(response.result.blocks), `${label}: AI fetch 应返回 blocks`);
return response.result;
}
function findBlockWithText(aggregate, text) {
const blocks = aggregate?.body?.blockDocument?.blocks;
if (!Array.isArray(blocks)) return null;
@@ -289,7 +329,11 @@ async function main() {
const bodyText = `Page Aggregate refresh body ${suffix}`;
const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
const screenshotPath = path.join(OUT_DIR, `${suffix}.png`);
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const createdIds = [];
@@ -325,6 +369,20 @@ async function main() {
title,
bodyText,
);
const beforeReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "before_reload");
const beforeReloadAiBlock = beforeReloadAiFetch.blocks.find((block) => block.blockId === beforeReloadBlock.blockId);
assert.equal(beforeReloadAiFetch.revision, beforeReloadAggregate.body?.revision, "刷新前 AI fetch revision 应与 Page Aggregate 一致");
assert.equal(
beforeReloadAiFetch.conflictDetectionKey,
beforeReloadAggregate.body?.conflictDetectionKey,
"刷新前 AI fetch conflictDetectionKey 应与 Page Aggregate 一致",
);
assert.equal(beforeReloadAiBlock?.text, bodyText, "刷新前 AI fetch 应回读 Page Aggregate 正文块");
assert.equal(
beforeReloadAggregate.tree?.pageSubtree?.rootNodeId,
target.documentId,
"刷新前 Page Aggregate pageSubtree.rootNodeId 应等于 documentId",
);
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await waitForPageTitleInput(page);
@@ -354,6 +412,20 @@ async function main() {
title,
bodyText,
);
const afterReloadAiFetch = await fetchAiDoc(context.request, target, viewer.userId, suffix, "after_reload");
const afterReloadAiBlock = afterReloadAiFetch.blocks.find((block) => block.blockId === afterReloadBlock.blockId);
assert.equal(afterReloadAiFetch.revision, afterReloadAggregate.body?.revision, "刷新后 AI fetch revision 应与 Page Aggregate 一致");
assert.equal(
afterReloadAiFetch.conflictDetectionKey,
afterReloadAggregate.body?.conflictDetectionKey,
"刷新后 AI fetch conflictDetectionKey 应与 Page Aggregate 一致",
);
assert.equal(afterReloadAiBlock?.text, bodyText, "刷新后 AI fetch 应回读 Page Aggregate 正文块");
assert.equal(
afterReloadAggregate.tree?.pageSubtree?.rootNodeId,
target.documentId,
"刷新后 Page Aggregate pageSubtree.rootNodeId 应等于 documentId",
);
await page.screenshot({ path: screenshotPath, fullPage: false });
const evidence = {
@@ -383,12 +455,23 @@ async function main() {
conflictDetectionKey: beforeReloadAggregate.body?.conflictDetectionKey ?? null,
pageOptions: beforeReloadAggregate.layout?.pageOptions ?? null,
blockProjectionVersion: beforeReloadAggregate.body?.blockProjectionVersion ?? null,
pageSubtree: pageSubtreeSummary(beforeReloadAggregate),
},
matchedBlock: {
blockId: beforeReloadBlock.blockId,
text: beforeReloadBlock.text,
revisionRef: beforeReloadBlock.revisionRef,
},
aiFetch: {
schema: beforeReloadAiFetch.schema,
revision: beforeReloadAiFetch.revision,
conflictDetectionKey: beforeReloadAiFetch.conflictDetectionKey,
matchedBlock: {
blockId: beforeReloadAiBlock?.blockId ?? null,
text: beforeReloadAiBlock?.text ?? null,
revisionRef: beforeReloadAiBlock?.revisionRef ?? null,
},
},
runtime: beforeReloadRuntime,
},
afterReload: {
@@ -398,12 +481,23 @@ async function main() {
conflictDetectionKey: afterReloadAggregate.body?.conflictDetectionKey ?? null,
pageOptions: afterReloadAggregate.layout?.pageOptions ?? null,
blockProjectionVersion: afterReloadAggregate.body?.blockProjectionVersion ?? null,
pageSubtree: pageSubtreeSummary(afterReloadAggregate),
},
matchedBlock: {
blockId: afterReloadBlock.blockId,
text: afterReloadBlock.text,
revisionRef: afterReloadBlock.revisionRef,
},
aiFetch: {
schema: afterReloadAiFetch.schema,
revision: afterReloadAiFetch.revision,
conflictDetectionKey: afterReloadAiFetch.conflictDetectionKey,
matchedBlock: {
blockId: afterReloadAiBlock?.blockId ?? null,
text: afterReloadAiBlock?.text ?? null,
revisionRef: afterReloadAiBlock?.revisionRef ?? null,
},
},
title: afterReloadTitle,
editorText: afterReloadEditorText,
runtime: afterReloadRuntime,
@@ -16,6 +16,7 @@ const {
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-conflict-idempotency-smoke");
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
async function callMnoteTool(request, payload) {
return requestJson(request, "/api/hermes/tools/mnote/call", {
@@ -87,7 +88,11 @@ async function main() {
};
await fs.mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
+446 -8
View File
@@ -17,6 +17,7 @@ const {
} = require("./tree-shell-smoke-helpers");
const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-tools-smoke");
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";
function blockById(blocks, blockId) {
return blocks.find((block) => block.blockId === blockId);
@@ -26,6 +27,16 @@ function blockTexts(blocks) {
return blocks.map((block) => block.text);
}
function aggregateBlocks(aggregate) {
const blocks = aggregate?.body?.blockDocument?.blocks;
assert(Array.isArray(blocks), "Page Aggregate 必须返回 body.blockDocument.blocks");
return blocks;
}
function aggregateBlockById(aggregate, blockId) {
return aggregateBlocks(aggregate).find((block) => block.blockId === blockId);
}
async function waitForVisibleTexts(page, expectedTexts) {
await page.waitForFunction(
({ expected }) => {
@@ -63,6 +74,16 @@ async function callMnoteTool(request, payload) {
});
}
async function fetchPageAggregate(request, workspaceId, documentId) {
const payload = await requestJson(
request,
`/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" },
);
assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
return payload.result;
}
async function fetchBlocks(request, target, suffix, label, actorId) {
const response = await callMnoteTool(request, {
toolName: "mnote.doc.fetch",
@@ -96,8 +117,14 @@ async function main() {
steps: [],
};
await fs.mkdir(OUT_DIR, { recursive: true });
const hermesTesterDir = path.join(process.cwd(), "tmp", "hermes-tester", `page-block-ai-tools-${suffix}`);
await fs.mkdir(hermesTesterDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const browser = await chromium.launch({
headless: true,
...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await context.newPage();
@@ -111,9 +138,28 @@ async function main() {
await renameDocument(context.request, target.workspaceId, target.documentId, title);
const initialContent = [
{ id: "heading_1", type: "heading", props: { level: 1 }, content: [{ type: "text", text: `标题 ${suffix}` }] },
{ id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
{ id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] },
{ id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] },
{
id: "heading_parent",
type: "heading",
props: { level: 2 },
content: [{ type: "text", text: `带子块标题 ${suffix}` }],
children: [
{ id: "heading_child", type: "paragraph", content: [{ type: "text", text: `标题子段落 ${suffix}` }] },
],
},
{ id: "list_item_1", type: "bullet_list_item", content: [{ type: "text", text: `列表项 ${suffix}` }] },
{ id: "table_1", type: "table", content: [{ type: "text", text: `表格块 ${suffix}` }] },
{
id: "mindmap_1",
type: "mindmap",
props: { mindmapId: `mindmap_smoke_${suffix}` },
content: [{ type: "text", text: `思维导图块 ${suffix}` }],
},
{ id: "resource_1", type: "resource", content: [{ type: "text", text: `资源块 ${suffix}` }] },
];
const seed = await callMnoteTool(context.request, {
toolName: "mnote.page.save",
@@ -136,16 +182,139 @@ async function main() {
evidence.steps.push({ name: "seed", commandName: seed.result.commandName });
let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId);
const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
await fs.writeFile(
path.join(hermesTesterDir, "page-aggregate.json"),
JSON.stringify(initialAggregate, null, 2),
"utf8",
);
const p1 = blockById(snapshot.blocks, "p_1");
const p2 = blockById(snapshot.blocks, "p_2");
const p3 = blockById(snapshot.blocks, "p_3");
assert(p1 && p2 && p3, "初始化后应能读取 p_1/p_2/p_3");
const heading = blockById(snapshot.blocks, "heading_1");
assert(heading && p1 && p2 && p3, "初始化后应能读取 heading_1/p_1/p_2/p_3");
assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef");
const initialAggregateBlocks = aggregateBlocks(initialAggregate);
assert.deepEqual(
initialAggregateBlocks.map((block) => block.blockId),
snapshot.blocks.map((block) => block.blockId),
"Page Aggregate blockDocument 与 mnote.doc.fetch 块顺序必须一致",
);
assert(
["heading_1", "p_1", "p_2", "p_3", "list_item_1"].every((blockId) => {
const block = aggregateBlockById(initialAggregate, blockId);
return block?.editable === true && Boolean(block.revisionRef);
}),
"普通可编辑块必须在 Page Aggregate 中带 blockId/revisionRef",
);
const complexBlocks = ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]
.map((blockId) => blockById(snapshot.blocks, blockId));
assert(complexBlocks.every(Boolean), "初始化后应能读取复杂块投影");
const unsupportedBlocks = ["table_1", "mindmap_1", "resource_1"].map((blockId) => blockById(snapshot.blocks, blockId));
assert(
unsupportedBlocks.every((block) => block.editable === false && block.unsupportedReason),
"table/mindmap/resource 不能在 AI 投影中伪装成完全可编辑块",
);
assert.deepEqual(
blockById(snapshot.blocks, "heading_parent").children,
["heading_child"],
"带子块标题必须在投影中保留 children,用于阻断 move_after",
);
evidence.steps.push({
name: "doc.fetch.initial",
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
blockIds: snapshot.blocks.map((block) => block.blockId),
aggregatePath: `/api/page-aggregate/${target.documentId}`,
aggregateEvidencePath: path.join(hermesTesterDir, "page-aggregate.json"),
aggregateBlockProjectionVersion: initialAggregate.body.blockProjectionVersion,
aggregateProjectionSource: initialAggregate.body.projectionSource,
aggregateBlockCount: initialAggregateBlocks.length,
unsupportedBlocks: unsupportedBlocks.map((block) => ({
blockId: block.blockId,
type: block.type,
editable: block.editable,
unsupportedReason: block.unsupportedReason,
})),
});
const outline = await callMnoteTool(context.request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_outline_${suffix}`,
runId: `run_outline_${suffix}`,
toolCallId: `call_outline_${suffix}`,
traceId: `trace_outline_${suffix}`,
capabilityScope: ["page.read"],
args: {
scope: "outline",
detail: "with_ids",
},
});
assert.equal(outline.ok, true, "doc.fetch outline 应成功");
assert.deepEqual(
outline.result.blocks.map((block) => block.blockId),
["heading_1", "heading_parent"],
"outline 应只返回标题块",
);
const truncated = await callMnoteTool(context.request, {
toolName: "mnote.doc.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_truncated_${suffix}`,
runId: `run_truncated_${suffix}`,
toolCallId: `call_truncated_${suffix}`,
traceId: `trace_truncated_${suffix}`,
capabilityScope: ["page.read"],
args: {
scope: "full",
detail: "with_ids",
maxBlocks: 2,
},
});
assert.equal(truncated.result.truncated, true, "maxBlocks 裁剪应返回 truncated=true");
assert.equal(truncated.result.blocks.length, 2, "maxBlocks=2 应只返回两个块");
assert(truncated.result.continuation, "truncated 结果必须返回 continuation");
const findP2 = await callMnoteTool(context.request, {
toolName: "mnote.doc.find",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_find_${suffix}`,
runId: `run_find_${suffix}`,
toolCallId: `call_find_${suffix}`,
traceId: `trace_find_${suffix}`,
capabilityScope: ["page.read"],
args: {
query: `第二段 ${suffix}`,
},
});
assert.equal(findP2.result.matches[0].blockId, "p_2", "doc.find 应定位第二段");
await fs.writeFile(
path.join(hermesTesterDir, "doc-fetch-find.json"),
JSON.stringify(
{
full: snapshot,
outline: outline.result,
truncated: truncated.result,
find: findP2.result,
},
null,
2,
),
"utf8",
);
evidence.steps.push({
name: "doc.fetch.find",
outlineBlockIds: outline.result.blocks.map((block) => block.blockId),
truncated: truncated.result.truncated,
findBlockId: findP2.result.matches[0].blockId,
evidencePath: path.join(hermesTesterDir, "doc-fetch-find.json"),
});
const blockFetch = await callMnoteTool(context.request, {
@@ -159,15 +328,180 @@ async function main() {
traceId: `trace_block_${suffix}`,
capabilityScope: ["block.read"],
args: {
blockId: "p_2",
blockId: findP2.result.matches[0].blockId,
includeChildren: true,
contextBefore: 1,
contextAfter: 1,
},
});
assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块");
assert(blockFetch.result.block.revisionRef, "block.fetch 应返回 revisionRef");
assert.equal(blockFetch.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
assert.equal(blockFetch.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
evidence.steps.push({ name: "block.fetch", blockId: "p_2" });
evidence.steps.push({
name: "block.fetch",
blockId: "p_2",
revisionRef: blockFetch.result.block.revisionRef,
before: blockFetch.result.context.before.map((block) => block.blockId),
after: blockFetch.result.context.after.map((block) => block.blockId),
});
const complexMoveBlocked = [];
for (const blockId of ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]) {
const complexBlock = blockById(snapshot.blocks, blockId);
const plan = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_complex_${suffix}`,
runId: `run_plan_complex_${suffix}_${blockId}`,
toolCallId: `call_plan_complex_${suffix}_${blockId}`,
traceId: `trace_plan_complex_${suffix}_${blockId}`,
idempotencyKey: `idem_plan_complex_${suffix}_${blockId}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_move_after",
blockId,
anchorBlockId: "p_1",
},
});
assert.equal(plan.result.blocked, true, `${blockId} move_after dry-run 必须阻断`);
const blockedMove = await callMnoteTool(context.request, {
toolName: "mnote.block.move_after",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_move_complex_${suffix}`,
runId: `run_move_complex_${suffix}_${blockId}`,
toolCallId: `call_move_complex_${suffix}_${blockId}`,
traceId: `trace_move_complex_${suffix}_${blockId}`,
idempotencyKey: `idem_move_complex_${suffix}_${blockId}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
blockId,
anchorBlockId: "p_1",
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
blockRevisionRef: complexBlock.revisionRef,
anchorRevisionRef: p1.revisionRef,
},
});
assert.equal(blockedMove.result.blocked, true, `${blockId} 正式 move_after 必须返回 blocked`);
assert.equal(
blockedMove.result.warnings[0].code,
"block_move_after_blocked",
`${blockId} 正式 move_after 必须返回阻断 warning`,
);
complexMoveBlocked.push({
blockId,
type: complexBlock.type,
editable: complexBlock.editable,
dryRunBlocked: plan.result.blocked,
writeBlocked: blockedMove.result.blocked,
warningCode: blockedMove.result.warnings[0].code,
});
}
const afterComplexBlocked = await fetchBlocks(context.request, target, suffix, "after_complex_blocked", actorId);
assert.equal(afterComplexBlocked.revision, snapshot.revision, "复杂块 move_after 阻断后 revision 不应变化");
assert.deepEqual(blockTexts(afterComplexBlocked.blocks), blockTexts(snapshot.blocks), "复杂块 move_after 阻断后正文不应变化");
evidence.steps.push({
name: "block.move_after.complex_blocked",
blocked: complexMoveBlocked,
revisionAfterBlocked: afterComplexBlocked.revision,
});
const dryRunBaselineTexts = blockTexts(snapshot.blocks);
const dryRunBaselineAggregate = await fetchPageAggregate(
context.request,
target.workspaceId,
target.documentId,
);
const replacePlan = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_replace_${suffix}`,
runId: `run_plan_replace_${suffix}`,
toolCallId: `call_plan_replace_${suffix}`,
traceId: `trace_plan_replace_${suffix}`,
idempotencyKey: `idem_plan_replace_${suffix}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_replace",
blockId: "p_2",
content: `dry-run 替换 ${suffix}`,
},
});
assert.equal(replacePlan.result.dryRun, true, "block_replace plan 必须是 dry-run");
assert.equal(replacePlan.result.diff[0].before, `第二段 ${suffix}`, "replace plan 应解释 before");
assert.equal(replacePlan.result.diff[0].after, `dry-run 替换 ${suffix}`, "replace plan 应解释 after");
const insertPlan = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_insert_${suffix}`,
runId: `run_plan_insert_${suffix}`,
toolCallId: `call_plan_insert_${suffix}`,
traceId: `trace_plan_insert_${suffix}`,
idempotencyKey: `idem_plan_insert_${suffix}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_insert_after",
anchorBlockId: "p_1",
content: `dry-run 插入 ${suffix}`,
},
});
assert.equal(insertPlan.result.dryRun, true, "block_insert_after plan 必须是 dry-run");
assert.equal(insertPlan.result.diff[0].after, `第一段 ${suffix}`, "insert plan 应解释 anchor after");
const blockedPlan = await callMnoteTool(context.request, {
toolName: "mnote.doc.plan_update",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_plan_blocked_${suffix}`,
runId: `run_plan_blocked_${suffix}`,
toolCallId: `call_plan_blocked_${suffix}`,
traceId: `trace_plan_blocked_${suffix}`,
idempotencyKey: `idem_plan_blocked_${suffix}`,
dryRun: true,
capabilityScope: ["block.write"],
args: {
command: "block_move_after",
blockId: "p_3",
anchorBlockId: "p_3",
},
});
assert.equal(blockedPlan.result.blocked, true, "不支持 move 场景应返回 blocked=true");
const dryRunAfterAggregate = await fetchPageAggregate(
context.request,
target.workspaceId,
target.documentId,
);
const dryRunAfter = await fetchBlocks(context.request, target, suffix, "after_dry_run", actorId);
assert.equal(dryRunAfter.revision, snapshot.revision, "dry-run 后 revision 不应变化");
assert.deepEqual(blockTexts(dryRunAfter.blocks), dryRunBaselineTexts, "dry-run 后正文不应变化");
assert.equal(
dryRunAfterAggregate.body.conflictDetectionKey,
dryRunBaselineAggregate.body.conflictDetectionKey,
"dry-run 后 Page Aggregate conflictDetectionKey 不应变化",
);
evidence.steps.push({
name: "plan_update.dry_run",
replaceDiff: replacePlan.result.diff[0],
insertDiff: insertPlan.result.diff[0],
blocked: blockedPlan.result.blocked,
revisionAfterDryRun: dryRunAfter.revision,
});
const replacedText = `第二段已替换 ${suffix}`;
const replace = await callMnoteTool(context.request, {
@@ -192,8 +526,25 @@ async function main() {
});
assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId);
const afterReplaceAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本");
evidence.steps.push({ name: "block.replace", changedBlocks: replace.result.changedBlocks });
assert.equal(
aggregateBlockById(afterReplaceAggregate, "p_2").text,
replacedText,
"替换后 Page Aggregate 必须回读新文本",
);
assert.equal(blockById(snapshot.blocks, "p_1").text, `第一段 ${suffix}`, "替换后相邻 p_1 不应变化");
assert.equal(blockById(snapshot.blocks, "p_3").text, `第三段 ${suffix}`, "替换后相邻 p_3 不应变化");
assert(blockById(snapshot.blocks, "p_2"), "替换后目标 block id 必须保持 p_2");
evidence.steps.push({
name: "block.replace",
changedBlocks: replace.result.changedBlocks,
targetBlockStillExists: Boolean(blockById(snapshot.blocks, "p_2")),
aggregateRevision: afterReplaceAggregate.body.revision,
aggregateConflictDetectionKey: afterReplaceAggregate.body.conflictDetectionKey,
aggregateText: aggregateBlockById(afterReplaceAggregate, "p_2").text,
adjacentTexts: [blockById(snapshot.blocks, "p_1").text, blockById(snapshot.blocks, "p_3").text],
});
const insertedText = `插入段 ${suffix}`;
const anchorAfterReplace = blockById(snapshot.blocks, "p_1");
@@ -211,7 +562,11 @@ async function main() {
capabilityScope: ["block.write"],
args: {
anchorBlockId: "p_1",
content: insertedText,
content: {
type: "todo",
props: { checked: false },
content: [{ type: "text", text: insertedText }],
},
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
anchorRevisionRef: anchorAfterReplace.revisionRef,
@@ -221,7 +576,73 @@ async function main() {
assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成");
snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId);
assert.equal(blockById(snapshot.blocks, insertedBlockId).text, insertedText, "插入后 doc.fetch 应回读新块");
evidence.steps.push({ name: "block.insert_after", insertedBlockId });
const orderAfterInsert = snapshot.blocks.map((block) => block.blockId);
assert.equal(
orderAfterInsert.indexOf(insertedBlockId),
orderAfterInsert.indexOf("p_1") + 1,
"插入块必须紧跟 p_1",
);
const insertedBlockFetch = await callMnoteTool(context.request, {
toolName: "mnote.block.fetch",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_insert_fetch_${suffix}`,
runId: `run_insert_fetch_${suffix}`,
toolCallId: `call_insert_fetch_${suffix}`,
traceId: `trace_insert_fetch_${suffix}`,
capabilityScope: ["block.read"],
args: {
blockId: insertedBlockId,
contextBefore: 1,
contextAfter: 1,
},
});
assert.equal(insertedBlockFetch.result.block.blockId, insertedBlockId, "block.fetch 应能读取新插入块");
assert.equal(insertedBlockFetch.result.block.type, "todo", "insert_after 应能插入 todo 块");
const replayInsertText = `不应重复插入 ${suffix}`;
const insertReplay = await callMnoteTool(context.request, {
toolName: "mnote.block.insert_after",
workspaceId: target.workspaceId,
documentId: target.documentId,
actorId,
sessionId: `sess_insert_replay_${suffix}`,
runId: `run_insert_replay_${suffix}`,
toolCallId: `call_insert_replay_${suffix}`,
traceId: `trace_insert_replay_${suffix}`,
idempotencyKey: `idem_insert_${suffix}`,
dryRun: false,
capabilityScope: ["block.write"],
args: {
anchorBlockId: "p_1",
content: replayInsertText,
revision: snapshot.revision,
conflictDetectionKey: snapshot.conflictDetectionKey,
anchorRevisionRef: blockById(snapshot.blocks, "p_1").revisionRef,
},
});
assert.equal(
insertReplay.audit.commandId,
insert.audit.commandId,
"重复 insert_after idempotencyKey 应返回缓存 commandId",
);
const afterInsertReplay = await fetchBlocks(context.request, target, suffix, "after_insert_replay", actorId);
assert.equal(
afterInsertReplay.blocks.filter((block) => block.blockId === insertedBlockId).length,
1,
"重复 insert_after idempotencyKey 不应产生第二个相同插入块",
);
assert(!afterInsertReplay.blocks.some((block) => block.text === replayInsertText), "重复 insert_after 不应写入新内容");
assert.equal(afterInsertReplay.revision, snapshot.revision, "重复 insert_after 后 revision 不应再次递增");
snapshot = afterInsertReplay;
evidence.steps.push({
name: "block.insert_after",
insertedBlockId,
insertedBlockType: insertedBlockFetch.result.block.type,
orderAfterInsert,
replayCommandId: insertReplay.audit.commandId,
revisionAfterReplay: snapshot.revision,
});
const moveBlock = blockById(snapshot.blocks, "p_3");
const moveAnchor = blockById(snapshot.blocks, "p_1");
@@ -269,14 +690,31 @@ async function main() {
snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId);
const order = snapshot.blocks.map((block) => block.blockId);
assert(order.indexOf("p_3") === order.indexOf("p_1") + 1, "移动后 p_3 必须紧跟 p_1");
evidence.steps.push({ name: "block.move_after", order, texts: blockTexts(snapshot.blocks) });
assert(blockById(snapshot.blocks, "p_3"), "移动后 moving block id 必须保持 p_3");
evidence.steps.push({
name: "block.move_after",
dryRunBlocked: moveDryRun.result.blocked,
dryRunDiff: moveDryRun.result.diff[0],
order,
texts: blockTexts(snapshot.blocks),
movingBlockStillExists: Boolean(blockById(snapshot.blocks, "p_3")),
});
await openDocument(page, target.workspaceId, target.documentId);
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
await page.reload({ waitUntil: "domcontentloaded" });
await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
const afterRefreshAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
assert.deepEqual(
aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId),
snapshot.blocks.map((block) => block.blockId),
"页面刷新后 Page Aggregate block ids 必须保持稳定",
);
const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
evidence.screenshot = screenshotPath;
evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`];
evidence.afterRefreshBlockIds = aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId);
evidence.finalTexts = blockTexts(snapshot.blocks);
evidence.ok = true;