mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
3081 lines
90 KiB
JavaScript
3081 lines
90 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import fs from "node:fs/promises";
|
||
import { createRequire, registerHooks } from "node:module";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import vm from "node:vm";
|
||
import { pruneProcessedMessageHashesFromFloor } from "../chat-history.js";
|
||
import {
|
||
onBeforeCombinePromptsController,
|
||
onGenerationAfterCommandsController,
|
||
} from "../event-binding.js";
|
||
import { onRerollController } from "../extraction-controller.js";
|
||
import {
|
||
GRAPH_LOAD_STATES,
|
||
GRAPH_METADATA_KEY,
|
||
GRAPH_PERSISTENCE_META_KEY,
|
||
MODULE_NAME,
|
||
} from "../graph-persistence.js";
|
||
import {
|
||
buildPersistedRecallRecord,
|
||
bumpPersistedRecallGenerationCount,
|
||
markPersistedRecallManualEdit,
|
||
readPersistedRecallFromUserMessage,
|
||
removePersistedRecallFromUserMessage,
|
||
resolveFinalRecallInjectionSource,
|
||
resolveGenerationTargetUserMessageIndex,
|
||
writePersistedRecallToUserMessage,
|
||
} from "../recall-persistence.js";
|
||
import {
|
||
BATCH_STAGE_ORDER,
|
||
BATCH_STAGE_SEVERITY,
|
||
clampInt,
|
||
createBatchStageStatus,
|
||
createBatchStatusSkeleton,
|
||
createGraphPersistenceState,
|
||
createRecallInputRecord,
|
||
createRecallRunResult,
|
||
createUiStatus,
|
||
finalizeBatchStatus,
|
||
getGenerationRecallHookStateFromResult,
|
||
getRecallHookLabel,
|
||
getStageNoticeDuration,
|
||
getStageNoticeTitle,
|
||
hashRecallInput,
|
||
isFreshRecallInputRecord,
|
||
isTerminalGenerationRecallHookState,
|
||
normalizeRecallInputText,
|
||
normalizeStageNoticeLevel,
|
||
pushBatchStageArtifact,
|
||
setBatchStageOutcome,
|
||
shouldRunRecallForTransaction,
|
||
} from "../ui-status.js";
|
||
|
||
const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||
const extensionsShimSource = [
|
||
"export const extension_settings = globalThis.__p0ExtensionSettings || {};",
|
||
"export function getContext(...args) {",
|
||
" return globalThis.SillyTavern?.getContext?.(...args) || null;",
|
||
"}",
|
||
].join("\n");
|
||
const scriptShimSource = [
|
||
"export function getRequestHeaders() {",
|
||
" return { 'Content-Type': 'application/json' };",
|
||
"}",
|
||
].join("\n");
|
||
const openAiShimSource = [
|
||
"export const chat_completion_sources = { CUSTOM: 'custom', OPENAI: 'openai' };",
|
||
"export async function sendOpenAIRequest(...args) {",
|
||
" if (typeof globalThis.__p0SendOpenAIRequest === 'function') {",
|
||
" return await globalThis.__p0SendOpenAIRequest(...args);",
|
||
" }",
|
||
" return { choices: [{ message: { content: '{}' } }] };",
|
||
"}",
|
||
].join("\n");
|
||
|
||
const extensionsShimUrl = `data:text/javascript,${encodeURIComponent(
|
||
extensionsShimSource,
|
||
)}`;
|
||
const scriptShimUrl = `data:text/javascript,${encodeURIComponent(
|
||
scriptShimSource,
|
||
)}`;
|
||
const openAiShimUrl = `data:text/javascript,${encodeURIComponent(
|
||
openAiShimSource,
|
||
)}`;
|
||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||
const indexPath = path.resolve(moduleDir, "../index.js");
|
||
|
||
registerHooks({
|
||
resolve(specifier, context, nextResolve) {
|
||
if (
|
||
specifier === "../../../extensions.js" ||
|
||
specifier === "../../../../extensions.js"
|
||
) {
|
||
return {
|
||
shortCircuit: true,
|
||
url: extensionsShimUrl,
|
||
};
|
||
}
|
||
if (specifier === "../../../../script.js") {
|
||
return {
|
||
shortCircuit: true,
|
||
url: scriptShimUrl,
|
||
};
|
||
}
|
||
if (specifier === "../../../openai.js") {
|
||
return {
|
||
shortCircuit: true,
|
||
url: openAiShimUrl,
|
||
};
|
||
}
|
||
return nextResolve(specifier, context);
|
||
},
|
||
});
|
||
|
||
const require = createRequire(import.meta.url);
|
||
const originalRequire = globalThis.require;
|
||
const originalP0ExtensionSettings = globalThis.__p0ExtensionSettings;
|
||
const originalP0SendOpenAIRequest = globalThis.__p0SendOpenAIRequest;
|
||
const originalStBmeTestOverrides = globalThis.__stBmeTestOverrides;
|
||
globalThis.__p0ExtensionSettings = {
|
||
st_bme: {},
|
||
};
|
||
globalThis.__stBmeTestOverrides = {};
|
||
globalThis.require = require;
|
||
|
||
const { createEmptyGraph, createNode, addNode, createEdge, addEdge } =
|
||
await import("../graph.js");
|
||
const { compressType } = await import("../compressor.js");
|
||
const { syncGraphVectorIndex } = await import("../vector-index.js");
|
||
const { extractMemories } = await import("../extractor.js");
|
||
const { consolidateMemories } = await import("../consolidator.js");
|
||
const {
|
||
createBatchJournalEntry,
|
||
buildReverseJournalRecoveryPlan,
|
||
normalizeGraphRuntimeState,
|
||
rollbackBatch,
|
||
} = await import("../runtime-state.js");
|
||
const { createDefaultTaskProfiles } = await import("../prompt-profiles.js");
|
||
const extensionsApi = await import("../../../../extensions.js");
|
||
const llm = await import("../llm.js");
|
||
const embedding = await import("../embedding.js");
|
||
|
||
if (originalRequire === undefined) {
|
||
delete globalThis.require;
|
||
} else {
|
||
globalThis.require = originalRequire;
|
||
}
|
||
|
||
if (originalP0ExtensionSettings === undefined) {
|
||
delete globalThis.__p0ExtensionSettings;
|
||
} else {
|
||
globalThis.__p0ExtensionSettings = originalP0ExtensionSettings;
|
||
}
|
||
|
||
if (originalP0SendOpenAIRequest === undefined) {
|
||
delete globalThis.__p0SendOpenAIRequest;
|
||
} else {
|
||
globalThis.__p0SendOpenAIRequest = originalP0SendOpenAIRequest;
|
||
}
|
||
|
||
if (originalStBmeTestOverrides === undefined) {
|
||
delete globalThis.__stBmeTestOverrides;
|
||
} else {
|
||
globalThis.__stBmeTestOverrides = originalStBmeTestOverrides;
|
||
}
|
||
|
||
const schema = [
|
||
{
|
||
id: "event",
|
||
label: "事件",
|
||
columns: [
|
||
{ name: "title" },
|
||
{ name: "summary" },
|
||
{ name: "participants" },
|
||
{ name: "status" },
|
||
],
|
||
compression: {
|
||
mode: "hierarchical",
|
||
threshold: 2,
|
||
},
|
||
},
|
||
{
|
||
id: "character",
|
||
label: "角色",
|
||
columns: [{ name: "name" }, { name: "state" }],
|
||
latestOnly: true,
|
||
},
|
||
];
|
||
|
||
function createBatchStageHarness() {
|
||
return fs.readFile(indexPath, "utf8").then((source) => {
|
||
const marker = "function notifyHistoryDirty(dirtyFrom, reason) {";
|
||
const start = source.indexOf("function shouldAdvanceProcessedHistory(");
|
||
const end = source.indexOf(marker);
|
||
if (start < 0 || end < 0 || end <= start) {
|
||
throw new Error("无法从 index.js 提取批次状态机定义");
|
||
}
|
||
const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
|
||
const context = {
|
||
console,
|
||
result: null,
|
||
extractionCount: 0,
|
||
currentGraph: null,
|
||
consolidateMemories: async () => {},
|
||
generateSynopsis: async () => {},
|
||
generateReflection: async () => {},
|
||
sleepCycle: () => {},
|
||
compressAll: async () => ({ created: 0, archived: 0 }),
|
||
syncVectorState: async () => ({
|
||
insertedHashes: [],
|
||
stats: { pending: 0 },
|
||
}),
|
||
getSchema: () => schema,
|
||
getEmbeddingConfig: () => null,
|
||
getVectorIndexStats: () => ({ pending: 0 }),
|
||
updateLastExtractedItems: () => {},
|
||
ensureCurrentGraphRuntimeState: () => {},
|
||
throwIfAborted: () => {},
|
||
isAbortError: () => false,
|
||
createAbortError: (message) => new Error(message),
|
||
BATCH_STAGE_ORDER,
|
||
BATCH_STAGE_SEVERITY,
|
||
createBatchStageStatus,
|
||
createBatchStatusSkeleton,
|
||
setBatchStageOutcome,
|
||
pushBatchStageArtifact,
|
||
finalizeBatchStatus,
|
||
createUiStatus,
|
||
};
|
||
vm.createContext(context);
|
||
vm.runInContext(
|
||
`${snippet}\nresult = { createBatchStatusSkeleton, finalizeBatchStatus, handleExtractionSuccess, setBatchStageOutcome, shouldAdvanceProcessedHistory };`,
|
||
context,
|
||
{ filename: indexPath },
|
||
);
|
||
return context;
|
||
});
|
||
}
|
||
|
||
function createGenerationRecallHarness() {
|
||
return fs.readFile(indexPath, "utf8").then((source) => {
|
||
const start = source.indexOf("const RECALL_INPUT_RECORD_TTL_MS = 60000;");
|
||
const end = source.indexOf("function onMessageReceived() {");
|
||
if (start < 0 || end < 0 || end <= start) {
|
||
throw new Error("无法从 index.js 提取生成召回事务定义");
|
||
}
|
||
const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
|
||
const context = {
|
||
console,
|
||
Date,
|
||
Map,
|
||
setTimeout,
|
||
clearTimeout,
|
||
__sendTextareaValue: "",
|
||
document: {
|
||
getElementById(id) {
|
||
if (
|
||
id === "send_textarea" &&
|
||
typeof context.__sendTextareaValue === "string" &&
|
||
context.__sendTextareaValue
|
||
) {
|
||
return { value: context.__sendTextareaValue };
|
||
}
|
||
return null;
|
||
},
|
||
},
|
||
result: null,
|
||
currentGraph: {},
|
||
isRecalling: false,
|
||
getCurrentChatId: () => "chat-main",
|
||
normalizeRecallInputText: (text = "") => String(text || "").trim(),
|
||
pendingRecallSendIntent: { text: "", hash: "", at: 0 },
|
||
pendingHostGenerationInputSnapshot: { text: "", hash: "", at: 0 },
|
||
lastRecallSentUserMessage: { text: "", hash: "", at: 0 },
|
||
getLatestUserChatMessage: (chat = []) =>
|
||
[...chat].reverse().find((message) => message?.is_user) || null,
|
||
getLastNonSystemChatMessage: (chat = []) =>
|
||
[...chat].reverse().find((message) => !message?.is_system) || null,
|
||
getSendTextareaValue: () => "",
|
||
getRecallUserMessageSourceLabel: (source = "") => source,
|
||
getRecallUserMessageSourceLabelController: (source = "") => source,
|
||
buildRecallRecentMessages: (
|
||
chat = [],
|
||
_limit,
|
||
syntheticUserMessage = "",
|
||
) =>
|
||
syntheticUserMessage
|
||
? [...chat, { is_user: true, mes: syntheticUserMessage }]
|
||
: [...chat],
|
||
getContext: () => ({
|
||
chatId: "chat-main",
|
||
chat: context.chat,
|
||
}),
|
||
chat: [],
|
||
runRecallCalls: [],
|
||
applyFinalCalls: [],
|
||
createRecallInputRecord,
|
||
createRecallRunResult,
|
||
hashRecallInput,
|
||
normalizeRecallInputText,
|
||
isFreshRecallInputRecord,
|
||
isTerminalGenerationRecallHookState,
|
||
shouldRunRecallForTransaction,
|
||
getGenerationRecallHookStateFromResult,
|
||
createUiStatus,
|
||
createGraphPersistenceState,
|
||
getRecallHookLabel,
|
||
getStageNoticeTitle,
|
||
getStageNoticeDuration,
|
||
normalizeStageNoticeLevel,
|
||
MODULE_NAME,
|
||
GRAPH_LOAD_STATES,
|
||
GRAPH_METADATA_KEY,
|
||
GRAPH_PERSISTENCE_META_KEY,
|
||
onBeforeCombinePromptsController,
|
||
onGenerationAfterCommandsController,
|
||
readPersistedRecallFromUserMessage: () => null,
|
||
resolveFinalRecallInjectionSource: ({
|
||
freshRecallResult = null,
|
||
} = {}) => ({
|
||
source: freshRecallResult?.didRecall ? "fresh" : "none",
|
||
injectionText: String(freshRecallResult?.injectionText || ""),
|
||
record: null,
|
||
}),
|
||
bumpPersistedRecallGenerationCount: () => null,
|
||
applyModuleInjectionPrompt: () => ({}),
|
||
getSettings: () => ({}),
|
||
triggerChatMetadataSave: () => "debounced",
|
||
refreshPanelLiveState: () => {},
|
||
resolveGenerationTargetUserMessageIndex: (
|
||
chat = [],
|
||
{ generationType } = {},
|
||
) => {
|
||
const normalized = String(generationType || "normal");
|
||
if (!Array.isArray(chat) || chat.length === 0) return null;
|
||
if (normalized === "normal")
|
||
return chat[chat.length - 1]?.is_user ? chat.length - 1 : null;
|
||
for (let index = chat.length - 1; index >= 0; index--)
|
||
if (chat[index]?.is_user) return index;
|
||
return null;
|
||
},
|
||
};
|
||
vm.createContext(context);
|
||
vm.runInContext(
|
||
`${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationAfterCommands, onBeforeCombinePrompts, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot };`,
|
||
context,
|
||
{ filename: indexPath },
|
||
);
|
||
context.applyFinalRecallInjectionForGeneration = (payload = {}) => {
|
||
context.applyFinalCalls.push({ ...payload });
|
||
return {
|
||
source: "fresh",
|
||
targetUserMessageIndex: null,
|
||
};
|
||
};
|
||
context.runRecall = async (options = {}) => {
|
||
context.runRecallCalls.push({ ...options });
|
||
return { status: "completed", didRecall: true, ok: true };
|
||
};
|
||
return context;
|
||
});
|
||
}
|
||
|
||
function createRerollHarness() {
|
||
return fs.readFile(indexPath, "utf8").then((source) => {
|
||
const rollbackStart = source.indexOf(
|
||
"async function rollbackGraphForReroll(",
|
||
);
|
||
const rollbackEnd = source.indexOf(
|
||
"async function recoverHistoryIfNeeded(",
|
||
);
|
||
const rerollStart = source.indexOf("async function onReroll(");
|
||
const rerollEnd = source.indexOf("async function onManualSleep()");
|
||
if (
|
||
rollbackStart < 0 ||
|
||
rollbackEnd < 0 ||
|
||
rerollStart < 0 ||
|
||
rerollEnd < 0 ||
|
||
rollbackEnd <= rollbackStart ||
|
||
rerollEnd <= rerollStart
|
||
) {
|
||
throw new Error("无法从 index.js 提取 reroll 定义");
|
||
}
|
||
const snippet = [
|
||
source.slice(rollbackStart, rollbackEnd),
|
||
source.slice(rerollStart, rerollEnd),
|
||
]
|
||
.join("\n")
|
||
.replace(/^export\s+/gm, "");
|
||
const context = {
|
||
console,
|
||
Date,
|
||
result: null,
|
||
currentGraph: null,
|
||
isExtracting: false,
|
||
extractionCount: 0,
|
||
lastExtractedItems: ["stale-node"],
|
||
lastExtractionStatus: { level: "idle" },
|
||
chat: [],
|
||
embeddingConfig: { mode: "backend" },
|
||
rollbackAffectedJournalsCalls: [],
|
||
deletedHashesCalls: [],
|
||
prepareVectorStateCalls: [],
|
||
recoveryPlans: [],
|
||
saveGraphToChatCalls: 0,
|
||
refreshPanelCalls: 0,
|
||
clearInjectionCalls: 0,
|
||
onManualExtractCalls: 0,
|
||
clearedHistoryDirty: null,
|
||
postRollbackGraph: null,
|
||
manualExtractLevel: "success",
|
||
ensureCurrentGraphRuntimeState() {
|
||
return context.currentGraph;
|
||
},
|
||
getContext() {
|
||
return {
|
||
chat: context.chat,
|
||
chatId: "chat-main",
|
||
};
|
||
},
|
||
getCurrentChatId() {
|
||
return "chat-main";
|
||
},
|
||
getAssistantTurns(chat = []) {
|
||
return chat.flatMap((message, index) =>
|
||
!message?.is_user && !message?.is_system ? [index] : [],
|
||
);
|
||
},
|
||
getLastProcessedAssistantFloor() {
|
||
return Number(
|
||
context.currentGraph?.historyState?.lastProcessedAssistantFloor ?? -1,
|
||
);
|
||
},
|
||
findJournalRecoveryPoint(graph, floor) {
|
||
return context.findJournalRecoveryPointImpl(graph, floor);
|
||
},
|
||
findJournalRecoveryPointImpl() {
|
||
return null;
|
||
},
|
||
buildReverseJournalRecoveryPlan(...args) {
|
||
return context.buildReverseJournalRecoveryPlanImpl(...args);
|
||
},
|
||
buildReverseJournalRecoveryPlanImpl() {
|
||
return {
|
||
backendDeleteHashes: [],
|
||
replayRequiredNodeIds: [],
|
||
pendingRepairFromFloor: null,
|
||
legacyGapFallback: false,
|
||
dirtyReason: "history-recovery-replay",
|
||
};
|
||
},
|
||
rollbackAffectedJournals(graph, journals) {
|
||
context.rollbackAffectedJournalsCalls.push({ graph, journals });
|
||
if (context.postRollbackGraph) {
|
||
context.currentGraph = context.postRollbackGraph;
|
||
}
|
||
},
|
||
normalizeGraphRuntimeState(graph) {
|
||
return graph;
|
||
},
|
||
getEmbeddingConfig() {
|
||
return context.embeddingConfig;
|
||
},
|
||
applyRecoveryPlanToVectorState(plan, floor) {
|
||
context.recoveryPlans.push({ plan, floor });
|
||
},
|
||
isBackendVectorConfig(config) {
|
||
return config?.mode === "backend";
|
||
},
|
||
async deleteBackendVectorHashesForRecovery(...args) {
|
||
context.deletedHashesCalls.push(args);
|
||
},
|
||
pruneProcessedMessageHashesFromFloor(graph, fromFloor) {
|
||
return pruneProcessedMessageHashesFromFloor(graph, fromFloor);
|
||
},
|
||
async prepareVectorStateForReplay(...args) {
|
||
context.prepareVectorStateCalls.push(args);
|
||
},
|
||
clearHistoryDirty(graph, result) {
|
||
context.clearedHistoryDirty = result;
|
||
graph.historyState ||= {};
|
||
graph.historyState.historyDirtyFrom = null;
|
||
graph.historyState.lastRecoveryResult = result;
|
||
},
|
||
buildRecoveryResult(status, extra = {}) {
|
||
return {
|
||
status,
|
||
...extra,
|
||
};
|
||
},
|
||
saveGraphToChat() {
|
||
context.saveGraphToChatCalls += 1;
|
||
return true;
|
||
},
|
||
refreshPanelLiveState() {
|
||
context.refreshPanelCalls += 1;
|
||
},
|
||
setRuntimeStatus(text, meta = "", level = "info") {
|
||
context.runtimeStatus = { text, meta, level };
|
||
},
|
||
clearInjectionState() {
|
||
context.clearInjectionCalls += 1;
|
||
},
|
||
async onManualExtract() {
|
||
context.onManualExtractCalls += 1;
|
||
context.lastExtractionStatus = { level: context.manualExtractLevel };
|
||
},
|
||
ensureGraphMutationReady() {
|
||
return true;
|
||
},
|
||
getGraphMutationBlockReason() {
|
||
return "graph-not-ready";
|
||
},
|
||
graphPersistenceState: {
|
||
loadState: "loaded",
|
||
},
|
||
createUiStatus,
|
||
onRerollController,
|
||
isAbortError: (e) => e?.name === "AbortError",
|
||
assertRecoveryChatStillActive() {
|
||
// no-op in test
|
||
},
|
||
toastr: {
|
||
info() {},
|
||
error() {},
|
||
success() {},
|
||
},
|
||
};
|
||
vm.createContext(context);
|
||
vm.runInContext(
|
||
`${snippet}\nresult = { rollbackGraphForReroll, onReroll };`,
|
||
context,
|
||
{ filename: indexPath },
|
||
);
|
||
return context;
|
||
});
|
||
}
|
||
|
||
function pushTestOverrides(patch = {}) {
|
||
const previous = globalThis.__stBmeTestOverrides || {};
|
||
globalThis.__stBmeTestOverrides = {
|
||
...previous,
|
||
...patch,
|
||
llm: {
|
||
...(previous.llm || {}),
|
||
...(patch.llm || {}),
|
||
},
|
||
embedding: {
|
||
...(previous.embedding || {}),
|
||
...(patch.embedding || {}),
|
||
},
|
||
};
|
||
|
||
return () => {
|
||
globalThis.__stBmeTestOverrides = previous;
|
||
};
|
||
}
|
||
|
||
class FakeClassList {
|
||
constructor(owner) {
|
||
this.owner = owner;
|
||
this.tokens = new Set();
|
||
}
|
||
|
||
setFromString(value = "") {
|
||
this.tokens = new Set(
|
||
String(value || "")
|
||
.split(/\s+/)
|
||
.filter(Boolean),
|
||
);
|
||
}
|
||
|
||
add(...tokens) {
|
||
for (const token of tokens) {
|
||
if (token) this.tokens.add(token);
|
||
}
|
||
this.owner._syncClassName();
|
||
}
|
||
|
||
remove(...tokens) {
|
||
for (const token of tokens) this.tokens.delete(token);
|
||
this.owner._syncClassName();
|
||
}
|
||
|
||
contains(token) {
|
||
return this.tokens.has(token);
|
||
}
|
||
|
||
toggle(token, force) {
|
||
if (force === true) {
|
||
this.tokens.add(token);
|
||
this.owner._syncClassName();
|
||
return true;
|
||
}
|
||
if (force === false) {
|
||
this.tokens.delete(token);
|
||
this.owner._syncClassName();
|
||
return false;
|
||
}
|
||
if (this.tokens.has(token)) {
|
||
this.tokens.delete(token);
|
||
this.owner._syncClassName();
|
||
return false;
|
||
}
|
||
this.tokens.add(token);
|
||
this.owner._syncClassName();
|
||
return true;
|
||
}
|
||
|
||
toString() {
|
||
return [...this.tokens].join(" ");
|
||
}
|
||
}
|
||
|
||
class FakeElement {
|
||
constructor(tagName, ownerDocument) {
|
||
this.tagName = String(tagName || "div").toUpperCase();
|
||
this.ownerDocument = ownerDocument;
|
||
this.children = [];
|
||
this.parentElement = null;
|
||
this.dataset = {};
|
||
this.attributes = new Map();
|
||
this.eventListeners = new Map();
|
||
this.classList = new FakeClassList(this);
|
||
this._className = "";
|
||
this.id = "";
|
||
this.textContent = "";
|
||
this.innerHTML = "";
|
||
this.disabled = false;
|
||
}
|
||
|
||
_syncClassName() {
|
||
this._className = this.classList.toString();
|
||
}
|
||
|
||
get className() {
|
||
return this._className;
|
||
}
|
||
|
||
set className(value) {
|
||
this._className = String(value || "");
|
||
this.classList.setFromString(this._className);
|
||
this._className = this.classList.toString();
|
||
}
|
||
|
||
get parentNode() {
|
||
return this.parentElement;
|
||
}
|
||
|
||
setAttribute(name, value) {
|
||
const key = String(name || "");
|
||
const normalized = String(value ?? "");
|
||
this.attributes.set(key, normalized);
|
||
if (key === "id") {
|
||
this.id = normalized;
|
||
} else if (key === "class") {
|
||
this.classList.setFromString(normalized);
|
||
this.className = this.classList.toString();
|
||
} else if (key.startsWith("data-")) {
|
||
const datasetKey = key
|
||
.slice(5)
|
||
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||
this.dataset[datasetKey] = normalized;
|
||
}
|
||
}
|
||
|
||
getAttribute(name) {
|
||
const key = String(name || "");
|
||
if (this.attributes.has(key)) return this.attributes.get(key);
|
||
if (key === "id") return this.id || null;
|
||
if (key === "class") return this.className || null;
|
||
if (key.startsWith("data-")) {
|
||
const datasetKey = key
|
||
.slice(5)
|
||
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||
return this.dataset[datasetKey] ?? null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
appendChild(child) {
|
||
if (!child) return child;
|
||
if (child.parentElement) {
|
||
child.parentElement.removeChild(child);
|
||
}
|
||
child.parentElement = this;
|
||
child.ownerDocument = this.ownerDocument;
|
||
this.children.push(child);
|
||
this.ownerDocument?._notifyMutation({
|
||
type: "childList",
|
||
target: this,
|
||
addedNodes: [child],
|
||
});
|
||
return child;
|
||
}
|
||
|
||
removeChild(child) {
|
||
const index = this.children.indexOf(child);
|
||
if (index >= 0) {
|
||
this.children.splice(index, 1);
|
||
child.parentElement = null;
|
||
this.ownerDocument?._notifyMutation({
|
||
type: "childList",
|
||
target: this,
|
||
removedNodes: [child],
|
||
});
|
||
}
|
||
return child;
|
||
}
|
||
|
||
remove() {
|
||
this.parentElement?.removeChild(this);
|
||
}
|
||
|
||
addEventListener(type, handler) {
|
||
const key = String(type || "");
|
||
const handlers = this.eventListeners.get(key) || [];
|
||
handlers.push(handler);
|
||
this.eventListeners.set(key, handlers);
|
||
}
|
||
|
||
dispatchEvent(event = {}) {
|
||
const key = String(event.type || "");
|
||
const handlers = this.eventListeners.get(key) || [];
|
||
for (const handler of handlers) {
|
||
handler({
|
||
stopPropagation() {},
|
||
preventDefault() {},
|
||
...event,
|
||
target: this,
|
||
currentTarget: this,
|
||
});
|
||
}
|
||
}
|
||
|
||
click() {
|
||
this.dispatchEvent({ type: "click" });
|
||
}
|
||
|
||
get isConnected() {
|
||
return Boolean(this.parentElement) || this === this.ownerDocument?.body;
|
||
}
|
||
|
||
querySelector(selector) {
|
||
return this.querySelectorAll(selector)[0] || null;
|
||
}
|
||
|
||
querySelectorAll(selector) {
|
||
return this.ownerDocument?._querySelectorAll(selector, this) || [];
|
||
}
|
||
}
|
||
|
||
class FakeDocument {
|
||
constructor() {
|
||
this.body = new FakeElement("body", this);
|
||
this._observers = new Set();
|
||
}
|
||
|
||
createElement(tagName) {
|
||
return new FakeElement(tagName, this);
|
||
}
|
||
|
||
contains(node) {
|
||
return Boolean(this._flatten(this.body).includes(node));
|
||
}
|
||
|
||
getElementById(id) {
|
||
return this._flatten(this.body).find((node) => node.id === id) || null;
|
||
}
|
||
|
||
querySelector(selector) {
|
||
return this.body.querySelector(selector);
|
||
}
|
||
|
||
querySelectorAll(selector) {
|
||
return this.body.querySelectorAll(selector);
|
||
}
|
||
|
||
_flatten(root) {
|
||
const nodes = [];
|
||
const visit = (node) => {
|
||
nodes.push(node);
|
||
for (const child of node.children) visit(child);
|
||
};
|
||
visit(root);
|
||
return nodes;
|
||
}
|
||
|
||
_matchesSimple(node, selector) {
|
||
if (!selector) return false;
|
||
if (selector.startsWith("#")) {
|
||
return node.id === selector.slice(1);
|
||
}
|
||
const attrMatches = [...selector.matchAll(/\[([^=\]]+)="([^\]]*)"\]/g)];
|
||
const attrless = selector.replace(/\[[^\]]+\]/g, "");
|
||
const classMatches = [...attrless.matchAll(/\.([A-Za-z0-9_-]+)/g)].map(
|
||
(m) => m[1],
|
||
);
|
||
const tagMatch = attrless.match(/^[A-Za-z][A-Za-z0-9_-]*/);
|
||
if (tagMatch && node.tagName.toLowerCase() !== tagMatch[0].toLowerCase())
|
||
return false;
|
||
for (const className of classMatches) {
|
||
if (!node.classList.contains(className)) return false;
|
||
}
|
||
for (const [, rawName, expected] of attrMatches) {
|
||
const actual = node.getAttribute(rawName);
|
||
if (String(actual ?? "") !== expected) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
_matchesSelectorChain(node, segments) {
|
||
if (!segments.length) return false;
|
||
if (!this._matchesSimple(node, segments[segments.length - 1])) return false;
|
||
let current = node.parentElement;
|
||
for (let index = segments.length - 2; index >= 0; index--) {
|
||
while (current && !this._matchesSimple(current, segments[index])) {
|
||
current = current.parentElement;
|
||
}
|
||
if (!current) return false;
|
||
current = current.parentElement;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
_querySelectorAll(selector, scopeRoot) {
|
||
const segments = String(selector || "")
|
||
.trim()
|
||
.split(/\s+/)
|
||
.filter(Boolean);
|
||
const nodes = this._flatten(scopeRoot);
|
||
return nodes.filter(
|
||
(node) =>
|
||
node !== scopeRoot && this._matchesSelectorChain(node, segments),
|
||
);
|
||
}
|
||
|
||
_registerObserver(observer) {
|
||
this._observers.add(observer);
|
||
}
|
||
|
||
_unregisterObserver(observer) {
|
||
this._observers.delete(observer);
|
||
}
|
||
|
||
_notifyMutation(record) {
|
||
for (const observer of this._observers) {
|
||
observer._notify(record);
|
||
}
|
||
}
|
||
}
|
||
|
||
class FakeMutationObserver {
|
||
constructor(callback, documentRef) {
|
||
this.callback = callback;
|
||
this.documentRef = documentRef;
|
||
this.active = false;
|
||
}
|
||
|
||
observe() {
|
||
this.active = true;
|
||
this.documentRef._registerObserver(this);
|
||
}
|
||
|
||
disconnect() {
|
||
this.active = false;
|
||
this.documentRef._unregisterObserver(this);
|
||
}
|
||
|
||
_notify(record) {
|
||
if (!this.active) return;
|
||
queueMicrotask(() => {
|
||
if (this.active) this.callback([record]);
|
||
});
|
||
}
|
||
}
|
||
|
||
function createDomHarness(chat) {
|
||
const document = new FakeDocument();
|
||
const chatRoot = document.createElement("div");
|
||
chatRoot.setAttribute("id", "chat");
|
||
document.body.appendChild(chatRoot);
|
||
const observerClass = class extends FakeMutationObserver {
|
||
constructor(callback) {
|
||
super(callback, document);
|
||
}
|
||
};
|
||
return { document, chatRoot, MutationObserver: observerClass, chat };
|
||
}
|
||
|
||
function createMessageElement(
|
||
document,
|
||
messageIndex,
|
||
{ stableId = true, withMesBlock = true, isUser = true } = {},
|
||
) {
|
||
const mes = document.createElement("div");
|
||
mes.classList.add("mes");
|
||
if (stableId) mes.setAttribute("mesid", String(messageIndex));
|
||
if (isUser) mes.classList.add("user_mes");
|
||
const block = document.createElement("div");
|
||
block.classList.add("mes_block");
|
||
const textWrap = document.createElement("div");
|
||
textWrap.classList.add("mes_text");
|
||
if (withMesBlock) {
|
||
block.appendChild(textWrap);
|
||
mes.appendChild(block);
|
||
} else {
|
||
mes.appendChild(textWrap);
|
||
}
|
||
return mes;
|
||
}
|
||
|
||
function appendLegacyBadge(document, messageElement) {
|
||
const badge = document.createElement("div");
|
||
badge.classList.add("st-bme-recall-badge");
|
||
messageElement.appendChild(badge);
|
||
return badge;
|
||
}
|
||
|
||
async function createRecallUiHarness({
|
||
chat,
|
||
graph = { nodes: [], edges: [] },
|
||
} = {}) {
|
||
const harness = createDomHarness(chat);
|
||
const previousDocument = globalThis.document;
|
||
globalThis.document = harness.document;
|
||
const source = await fs.readFile(indexPath, "utf8");
|
||
const start = source.indexOf("function debugWithThrottle(");
|
||
const end = source.indexOf("async function rerunRecallForMessage(");
|
||
if (start < 0 || end < 0 || end <= start) {
|
||
throw new Error("无法从 index.js 提取 Recall UI 逻辑");
|
||
}
|
||
const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
|
||
const context = {
|
||
console,
|
||
Date,
|
||
JSON,
|
||
Math,
|
||
Map,
|
||
Set,
|
||
Array,
|
||
Number,
|
||
String,
|
||
Object,
|
||
RegExp,
|
||
parseInt: Number.parseInt,
|
||
setTimeout,
|
||
clearTimeout,
|
||
queueMicrotask,
|
||
document: harness.document,
|
||
currentGraph: graph,
|
||
persistedRecallUiRefreshTimer: null,
|
||
persistedRecallUiRefreshObserver: null,
|
||
persistedRecallUiRefreshSession: 0,
|
||
PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS: [0, 10, 20],
|
||
PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS: 0,
|
||
persistedRecallUiDiagnosticTimestamps: new Map(),
|
||
persistedRecallPersistDiagnosticTimestamps: new Map(),
|
||
getContext: () => ({ chat }),
|
||
getSettings: () => ({ panelTheme: "crimson" }),
|
||
triggerChatMetadataSave: () => "debounced",
|
||
estimateTokens: (text = "") =>
|
||
String(text || "")
|
||
.trim()
|
||
.split(/\s+/)
|
||
.filter(Boolean).length || 1,
|
||
toastr: {
|
||
success() {},
|
||
warning() {},
|
||
info() {},
|
||
},
|
||
openRecallSidebar() {},
|
||
readPersistedRecallFromUserMessage,
|
||
removePersistedRecallFromUserMessage,
|
||
writePersistedRecallToUserMessage,
|
||
buildPersistedRecallRecord,
|
||
markPersistedRecallManualEdit,
|
||
createRecallCardElement: null,
|
||
updateRecallCardData: null,
|
||
globalThis: null,
|
||
result: null,
|
||
};
|
||
context.globalThis = context;
|
||
const recallUiModule = await import("../recall-message-ui.js");
|
||
context.createRecallCardElement = recallUiModule.createRecallCardElement;
|
||
context.updateRecallCardData = recallUiModule.updateRecallCardData;
|
||
context.MutationObserver = harness.MutationObserver;
|
||
vm.createContext(context);
|
||
vm.runInContext(
|
||
`${snippet}\nresult = { refreshPersistedRecallMessageUi, schedulePersistedRecallMessageUiRefresh, cleanupPersistedRecallMessageUi, resolveMessageIndexFromElement, resolveRecallCardAnchor };`,
|
||
context,
|
||
{ filename: indexPath },
|
||
);
|
||
return {
|
||
...harness,
|
||
context,
|
||
api: context.result,
|
||
restoreGlobals() {
|
||
globalThis.document = previousDocument;
|
||
},
|
||
};
|
||
}
|
||
|
||
async function testRecallCardMountsOnStandardUserMessageDom() {
|
||
const chat = [
|
||
{
|
||
is_user: true,
|
||
mes: "user-0",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "recall-0",
|
||
selectedNodeIds: ["n1"],
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
];
|
||
const harness = await createRecallUiHarness({ chat });
|
||
const messageElement = createMessageElement(harness.document, 0, {
|
||
stableId: true,
|
||
withMesBlock: true,
|
||
isUser: true,
|
||
});
|
||
harness.chatRoot.appendChild(messageElement);
|
||
|
||
try {
|
||
const summary = harness.api.refreshPersistedRecallMessageUi();
|
||
assert.equal(summary.status, "rendered");
|
||
assert.equal(summary.renderedCount, 1);
|
||
assert.equal(
|
||
harness.chatRoot.querySelectorAll(".bme-recall-card").length,
|
||
1,
|
||
);
|
||
assert.equal(
|
||
harness.chatRoot.querySelectorAll(".mes_block .bme-recall-card").length,
|
||
1,
|
||
);
|
||
} finally {
|
||
harness.restoreGlobals();
|
||
}
|
||
}
|
||
|
||
async function testRecallCardSkipsMountWithoutStableMessageIndex() {
|
||
const chat = [
|
||
{
|
||
is_user: true,
|
||
mes: "user-0",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "recall-0",
|
||
selectedNodeIds: ["n1"],
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
];
|
||
const harness = await createRecallUiHarness({ chat });
|
||
const messageElement = createMessageElement(harness.document, 0, {
|
||
stableId: false,
|
||
withMesBlock: true,
|
||
isUser: true,
|
||
});
|
||
harness.chatRoot.appendChild(messageElement);
|
||
|
||
try {
|
||
const summary = harness.api.refreshPersistedRecallMessageUi();
|
||
assert.equal(summary.status, "waiting_dom");
|
||
assert.deepEqual(Array.from(summary.waitingMessageIndices), [0]);
|
||
assert.equal(
|
||
harness.chatRoot.querySelectorAll(".bme-recall-card").length,
|
||
0,
|
||
);
|
||
} finally {
|
||
harness.restoreGlobals();
|
||
}
|
||
}
|
||
|
||
async function testRecallCardDelayedDomInsertionEventuallyRenders() {
|
||
const chat = [
|
||
{
|
||
is_user: true,
|
||
mes: "user-0",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "recall-0",
|
||
selectedNodeIds: ["n1"],
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
];
|
||
const harness = await createRecallUiHarness({ chat });
|
||
try {
|
||
let updateCalls = 0;
|
||
const originalUpdateRecallCardData = harness.context.updateRecallCardData;
|
||
harness.context.updateRecallCardData = (...args) => {
|
||
updateCalls += 1;
|
||
return originalUpdateRecallCardData(...args);
|
||
};
|
||
|
||
harness.api.schedulePersistedRecallMessageUiRefresh();
|
||
await waitForTick();
|
||
const messageElement = createMessageElement(harness.document, 0, {
|
||
stableId: true,
|
||
withMesBlock: true,
|
||
isUser: true,
|
||
});
|
||
harness.chatRoot.appendChild(messageElement);
|
||
await waitForTick();
|
||
await waitForTick();
|
||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||
await waitForTick();
|
||
|
||
assert.equal(
|
||
harness.chatRoot.querySelectorAll(".bme-recall-card").length,
|
||
1,
|
||
);
|
||
assert.equal(
|
||
updateCalls,
|
||
0,
|
||
"observer 先触发后不应再被旧 timeout 重复刷新",
|
||
);
|
||
} finally {
|
||
harness.restoreGlobals();
|
||
}
|
||
}
|
||
|
||
async function testRecallCardDoesNotMountOnNonUserFloor() {
|
||
const chat = [
|
||
{
|
||
is_user: false,
|
||
mes: "assistant-0",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "recall-0",
|
||
selectedNodeIds: ["n1"],
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
];
|
||
const harness = await createRecallUiHarness({ chat });
|
||
const messageElement = createMessageElement(harness.document, 0, {
|
||
stableId: true,
|
||
withMesBlock: true,
|
||
isUser: false,
|
||
});
|
||
harness.chatRoot.appendChild(messageElement);
|
||
|
||
try {
|
||
const summary = harness.api.refreshPersistedRecallMessageUi();
|
||
assert.equal(summary.status, "skipped_non_user");
|
||
assert.deepEqual(Array.from(summary.skippedNonUserIndices), [0]);
|
||
assert.equal(
|
||
harness.chatRoot.querySelectorAll(".bme-recall-card").length,
|
||
0,
|
||
);
|
||
} finally {
|
||
harness.restoreGlobals();
|
||
}
|
||
}
|
||
|
||
async function testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates() {
|
||
const chat = [
|
||
{
|
||
is_user: true,
|
||
mes: "user-0",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "recall-0",
|
||
selectedNodeIds: ["n1", "n2"],
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
];
|
||
const harness = await createRecallUiHarness({ chat });
|
||
const messageElement = createMessageElement(harness.document, 0, {
|
||
stableId: true,
|
||
withMesBlock: true,
|
||
isUser: true,
|
||
});
|
||
const staleCard = harness.document.createElement("div");
|
||
staleCard.classList.add("bme-recall-card");
|
||
staleCard.dataset.messageIndex = "999";
|
||
staleCard._bmeDestroyRenderer = () => {
|
||
staleCard.dataset.destroyed = "1";
|
||
};
|
||
appendLegacyBadge(harness.document, messageElement);
|
||
messageElement.appendChild(staleCard);
|
||
harness.chatRoot.appendChild(messageElement);
|
||
|
||
try {
|
||
harness.api.refreshPersistedRecallMessageUi();
|
||
harness.api.refreshPersistedRecallMessageUi();
|
||
|
||
assert.equal(
|
||
harness.chatRoot.querySelectorAll(".st-bme-recall-badge").length,
|
||
0,
|
||
);
|
||
assert.equal(
|
||
harness.chatRoot.querySelectorAll(".bme-recall-card").length,
|
||
1,
|
||
);
|
||
assert.equal(staleCard.dataset.destroyed, "1");
|
||
} finally {
|
||
harness.restoreGlobals();
|
||
}
|
||
}
|
||
|
||
async function testRecallCardExpandedContentRerendersAfterRecordUpdate() {
|
||
const chat = [
|
||
{
|
||
is_user: true,
|
||
mes: "user-0",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "recall-0",
|
||
selectedNodeIds: ["n1"],
|
||
recallSource: "before",
|
||
tokenEstimate: 8,
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
];
|
||
const harness = await createRecallUiHarness({ chat });
|
||
const messageElement = createMessageElement(harness.document, 0, {
|
||
stableId: true,
|
||
withMesBlock: true,
|
||
isUser: true,
|
||
});
|
||
harness.chatRoot.appendChild(messageElement);
|
||
|
||
try {
|
||
let summary = harness.api.refreshPersistedRecallMessageUi();
|
||
assert.equal(summary.status, "rendered");
|
||
|
||
let card = harness.chatRoot.querySelector(".bme-recall-card");
|
||
card.querySelector(".bme-recall-bar")?.click();
|
||
assert.equal(card.classList.contains("expanded"), true);
|
||
const signatureBefore = card.dataset.expandedRenderSignature || "";
|
||
assert.equal(card.querySelector(".bme-recall-meta-tag"), null);
|
||
|
||
chat[0].extra.bme_recall = buildPersistedRecallRecord(
|
||
{
|
||
injectionText: "recall-1",
|
||
selectedNodeIds: ["n1", "n2"],
|
||
recallSource: "after",
|
||
tokenEstimate: 13,
|
||
manuallyEdited: true,
|
||
nowIso: "2026-01-01T00:01:00.000Z",
|
||
},
|
||
chat[0].extra.bme_recall,
|
||
);
|
||
|
||
summary = harness.api.refreshPersistedRecallMessageUi();
|
||
assert.equal(summary.status, "rendered");
|
||
|
||
card = harness.chatRoot.querySelector(".bme-recall-card");
|
||
assert.equal(card.dataset.updatedAt, "2026-01-01T00:01:00.000Z");
|
||
assert.equal(
|
||
card.querySelector(".bme-recall-count-badge")?.textContent,
|
||
"记忆 2",
|
||
);
|
||
assert.equal(
|
||
card.querySelector(".bme-recall-token-hint")?.textContent,
|
||
"~13 tokens",
|
||
);
|
||
const metaElements = card.querySelectorAll(".bme-recall-meta");
|
||
const latestMeta = metaElements[metaElements.length - 1] || null;
|
||
const latestTag =
|
||
card.querySelectorAll(".bme-recall-meta-tag").pop() || null;
|
||
assert.ok(latestMeta?.textContent.includes("来源: after"));
|
||
assert.equal(latestTag?.textContent, "✍ 手动编辑");
|
||
assert.notEqual(card.dataset.expandedRenderSignature, signatureBefore);
|
||
} finally {
|
||
harness.restoreGlobals();
|
||
}
|
||
}
|
||
|
||
async function testRecallCardUserTextRefreshesWithoutCardRecreate() {
|
||
const chat = [
|
||
{
|
||
is_user: true,
|
||
mes: "before-user",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "recall-0",
|
||
selectedNodeIds: ["n1"],
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
];
|
||
const harness = await createRecallUiHarness({ chat });
|
||
const messageElement = createMessageElement(harness.document, 0, {
|
||
stableId: true,
|
||
withMesBlock: true,
|
||
isUser: true,
|
||
});
|
||
harness.chatRoot.appendChild(messageElement);
|
||
|
||
try {
|
||
harness.api.refreshPersistedRecallMessageUi();
|
||
const firstCard = harness.chatRoot.querySelector(".bme-recall-card");
|
||
assert.equal(
|
||
firstCard.querySelector(".bme-recall-user-text")?.textContent,
|
||
"before-user",
|
||
);
|
||
|
||
chat[0].mes = "after-user";
|
||
harness.api.refreshPersistedRecallMessageUi();
|
||
|
||
const secondCard = harness.chatRoot.querySelector(".bme-recall-card");
|
||
assert.equal(secondCard, firstCard);
|
||
assert.equal(
|
||
secondCard.querySelector(".bme-recall-user-text")?.textContent,
|
||
"after-user",
|
||
);
|
||
} finally {
|
||
harness.restoreGlobals();
|
||
}
|
||
}
|
||
|
||
function makeEvent(seq, title) {
|
||
return createNode({
|
||
type: "event",
|
||
seq,
|
||
fields: {
|
||
title,
|
||
summary: `${title} 摘要`,
|
||
participants: "Alice",
|
||
status: "active",
|
||
},
|
||
});
|
||
}
|
||
|
||
async function testCompressorMigratesEdgesToCompressedNode() {
|
||
const graph = createEmptyGraph();
|
||
const external = createNode({
|
||
type: "character",
|
||
seq: 0,
|
||
fields: { name: "Alice", state: "awake" },
|
||
});
|
||
const first = makeEvent(1, "事件1");
|
||
const second = makeEvent(2, "事件2");
|
||
addNode(graph, external);
|
||
addNode(graph, first);
|
||
addNode(graph, second);
|
||
addEdge(
|
||
graph,
|
||
createEdge({
|
||
fromId: first.id,
|
||
toId: external.id,
|
||
relation: "mentions",
|
||
strength: 0.7,
|
||
}),
|
||
);
|
||
|
||
const restoreOverrides = pushTestOverrides({
|
||
llm: {
|
||
async callLLMForJSON() {
|
||
return {
|
||
fields: {
|
||
title: "压缩事件",
|
||
summary: "合并摘要",
|
||
participants: "Alice",
|
||
status: "done",
|
||
},
|
||
};
|
||
},
|
||
},
|
||
});
|
||
|
||
try {
|
||
const result = await compressType({
|
||
graph,
|
||
typeDef: schema[0],
|
||
embeddingConfig: null,
|
||
force: true,
|
||
settings: {},
|
||
});
|
||
assert.equal(result.created, 1);
|
||
|
||
const compressed = graph.nodes.find(
|
||
(node) => node.level === 1 && !node.archived,
|
||
);
|
||
assert.ok(compressed);
|
||
const migrated = graph.edges.find(
|
||
(edge) =>
|
||
edge.fromId === compressed.id &&
|
||
edge.toId === external.id &&
|
||
edge.relation === "mentions" &&
|
||
!edge.invalidAt &&
|
||
!edge.expiredAt,
|
||
);
|
||
assert.ok(migrated);
|
||
} finally {
|
||
restoreOverrides();
|
||
}
|
||
}
|
||
|
||
async function testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure() {
|
||
const graph = createEmptyGraph();
|
||
const first = makeEvent(1, "向量事件1");
|
||
const second = makeEvent(2, "向量事件2");
|
||
addNode(graph, first);
|
||
addNode(graph, second);
|
||
graph.vectorIndexState.dirty = true;
|
||
graph.vectorIndexState.lastWarning = "旧 warning";
|
||
|
||
const restoreOverrides = pushTestOverrides({
|
||
embedding: {
|
||
async embedBatch() {
|
||
return [[0.1, 0.2], null];
|
||
},
|
||
},
|
||
});
|
||
|
||
try {
|
||
const result = await syncGraphVectorIndex(
|
||
graph,
|
||
{
|
||
mode: "direct",
|
||
source: "direct",
|
||
apiUrl: "https://example.com/v1",
|
||
model: "text-embedding-3-small",
|
||
},
|
||
{},
|
||
);
|
||
|
||
assert.equal(result.insertedHashes.length, 1);
|
||
assert.equal(graph.vectorIndexState.dirty, true);
|
||
assert.equal(typeof result.stats.pending, "number");
|
||
assert.equal(graph.vectorIndexState.lastStats, result.stats);
|
||
assert.match(
|
||
graph.vectorIndexState.lastWarning,
|
||
/部分节点 embedding 生成失败/,
|
||
);
|
||
assert.equal(
|
||
graph.vectorIndexState.lastWarning,
|
||
"部分节点 embedding 生成失败,向量索引仍待修复",
|
||
);
|
||
assert.equal(second.embedding, null);
|
||
} finally {
|
||
restoreOverrides();
|
||
}
|
||
}
|
||
|
||
async function testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing() {
|
||
const graph = createEmptyGraph();
|
||
const target = createNode({
|
||
type: "event",
|
||
seq: 3,
|
||
fields: {
|
||
title: "旧记忆",
|
||
summary: "旧摘要",
|
||
participants: "Alice",
|
||
status: "active",
|
||
},
|
||
});
|
||
const incoming = createNode({
|
||
type: "event",
|
||
seq: 8,
|
||
fields: {
|
||
title: "新记忆",
|
||
summary: "新摘要",
|
||
participants: "Alice",
|
||
status: "updated",
|
||
},
|
||
});
|
||
target.embedding = [0.9, 0.1];
|
||
addNode(graph, target);
|
||
addNode(graph, incoming);
|
||
|
||
const restoreOverrides = pushTestOverrides({
|
||
embedding: {
|
||
async embedBatch() {
|
||
return [[0.2, 0.3]];
|
||
},
|
||
searchSimilar() {
|
||
return [{ nodeId: target.id, score: 0.99 }];
|
||
},
|
||
},
|
||
llm: {
|
||
async callLLMForJSON() {
|
||
return {
|
||
results: [
|
||
{
|
||
node_id: incoming.id,
|
||
action: "merge",
|
||
merge_target_id: "missing-node-id",
|
||
reason: "故意触发无效 merge target 回退",
|
||
},
|
||
],
|
||
};
|
||
},
|
||
},
|
||
});
|
||
|
||
try {
|
||
const stats = await consolidateMemories({
|
||
graph,
|
||
newNodeIds: [incoming.id],
|
||
embeddingConfig: {
|
||
mode: "direct",
|
||
source: "direct",
|
||
apiUrl: "https://example.com/v1",
|
||
model: "text-embedding-3-small",
|
||
},
|
||
settings: {},
|
||
});
|
||
|
||
assert.equal(stats.merged, 0);
|
||
assert.equal(stats.kept, 1);
|
||
assert.equal(incoming.archived, false);
|
||
assert.deepEqual(target.embedding, [0.9, 0.1]);
|
||
} finally {
|
||
restoreOverrides();
|
||
}
|
||
}
|
||
|
||
async function testExtractorFailsOnUnknownOperation() {
|
||
const graph = createEmptyGraph();
|
||
const restoreOverrides = pushTestOverrides({
|
||
llm: {
|
||
async callLLMForJSON() {
|
||
return {
|
||
operations: [{ action: "nonsense", foo: 1 }],
|
||
};
|
||
},
|
||
},
|
||
});
|
||
|
||
try {
|
||
const result = await extractMemories({
|
||
graph,
|
||
messages: [{ seq: 4, role: "assistant", content: "测试非法操作" }],
|
||
startSeq: 4,
|
||
endSeq: 4,
|
||
schema,
|
||
embeddingConfig: null,
|
||
settings: {},
|
||
});
|
||
|
||
assert.equal(result.success, false);
|
||
assert.match(result.error, /未知操作类型/);
|
||
assert.equal(graph.lastProcessedSeq, -1);
|
||
} finally {
|
||
restoreOverrides();
|
||
}
|
||
}
|
||
|
||
async function testConsolidatorMergeUpdatesSeqRange() {
|
||
const graph = createEmptyGraph();
|
||
const target = createNode({
|
||
type: "event",
|
||
seq: 3,
|
||
seqRange: [3, 4],
|
||
fields: {
|
||
title: "旧记忆",
|
||
summary: "旧摘要",
|
||
participants: "Alice",
|
||
status: "active",
|
||
},
|
||
});
|
||
target.embedding = [0.8, 0.2];
|
||
const incoming = createNode({
|
||
type: "event",
|
||
seq: 8,
|
||
seqRange: [8, 9],
|
||
fields: {
|
||
title: "新记忆",
|
||
summary: "新摘要",
|
||
participants: "Alice",
|
||
status: "updated",
|
||
},
|
||
});
|
||
addNode(graph, target);
|
||
addNode(graph, incoming);
|
||
|
||
const restoreOverrides = pushTestOverrides({
|
||
embedding: {
|
||
async embedBatch() {
|
||
return [[0.4, 0.5]];
|
||
},
|
||
searchSimilar() {
|
||
return [{ nodeId: target.id, score: 0.99 }];
|
||
},
|
||
},
|
||
llm: {
|
||
async callLLMForJSON() {
|
||
return {
|
||
results: [
|
||
{
|
||
node_id: incoming.id,
|
||
action: "merge",
|
||
merge_target_id: target.id,
|
||
merged_fields: { summary: "合并后摘要" },
|
||
},
|
||
],
|
||
};
|
||
},
|
||
},
|
||
});
|
||
|
||
try {
|
||
const stats = await consolidateMemories({
|
||
graph,
|
||
newNodeIds: [incoming.id],
|
||
embeddingConfig: {
|
||
mode: "direct",
|
||
source: "direct",
|
||
apiUrl: "https://example.com/v1",
|
||
model: "text-embedding-3-small",
|
||
},
|
||
settings: {},
|
||
});
|
||
|
||
assert.equal(stats.merged, 1);
|
||
assert.deepEqual(target.seqRange, [3, 9]);
|
||
assert.equal(target.seq, 8);
|
||
assert.equal(target.fields.summary, "合并后摘要");
|
||
assert.equal(target.embedding, null);
|
||
assert.equal(incoming.archived, true);
|
||
} finally {
|
||
restoreOverrides();
|
||
}
|
||
}
|
||
|
||
async function testBatchJournalVectorDeltaCapturesRecoveryFields() {
|
||
const before = normalizeGraphRuntimeState(createEmptyGraph(), "chat-a");
|
||
const after = normalizeGraphRuntimeState(createEmptyGraph(), "chat-a");
|
||
const beforeNode = createNode({
|
||
type: "event",
|
||
seq: 1,
|
||
fields: { title: "旧", summary: "旧", participants: "A", status: "old" },
|
||
});
|
||
beforeNode.id = "node-before";
|
||
const afterNode = createNode({
|
||
type: "event",
|
||
seq: 1,
|
||
fields: { title: "新", summary: "新", participants: "A", status: "new" },
|
||
});
|
||
afterNode.id = "node-before";
|
||
addNode(before, beforeNode);
|
||
addNode(after, afterNode);
|
||
before.vectorIndexState.hashToNodeId = { hash_old: "node-before" };
|
||
before.vectorIndexState.nodeToHash = { "node-before": "hash_old" };
|
||
after.vectorIndexState.hashToNodeId = {
|
||
hash_new: "node-before",
|
||
hash_inserted: "node-extra",
|
||
};
|
||
after.vectorIndexState.nodeToHash = {
|
||
"node-before": "hash_new",
|
||
"node-extra": "hash_inserted",
|
||
};
|
||
after.vectorIndexState.replayRequiredNodeIds = ["node-before", "node-extra"];
|
||
|
||
const journal = createBatchJournalEntry(before, after, {
|
||
processedRange: [4, 6],
|
||
vectorHashesInserted: ["hash_inserted"],
|
||
});
|
||
|
||
assert.deepEqual(journal.vectorDelta.insertedHashes.sort(), [
|
||
"hash_inserted",
|
||
"hash_new",
|
||
]);
|
||
assert.deepEqual(journal.vectorDelta.removedHashes, ["hash_old"]);
|
||
assert.deepEqual(journal.vectorDelta.touchedNodeIds.sort(), [
|
||
"node-before",
|
||
"node-extra",
|
||
]);
|
||
assert.deepEqual(journal.vectorDelta.replayRequiredNodeIds.sort(), [
|
||
"node-before",
|
||
"node-extra",
|
||
]);
|
||
assert.deepEqual(journal.vectorDelta.backendDeleteHashes, ["hash_old"]);
|
||
assert.deepEqual(journal.vectorDelta.replacedMappings, [
|
||
{ nodeId: "node-before", previousHash: "hash_old", nextHash: "hash_new" },
|
||
{ nodeId: "node-extra", previousHash: "", nextHash: "hash_inserted" },
|
||
]);
|
||
}
|
||
|
||
async function testReverseJournalRecoveryPlanLegacyFallback() {
|
||
const recoveryPlan = buildReverseJournalRecoveryPlan(
|
||
[
|
||
{
|
||
processedRange: [5, 7],
|
||
vectorDelta: {
|
||
insertedHashes: ["hash_1"],
|
||
},
|
||
},
|
||
],
|
||
5,
|
||
);
|
||
|
||
assert.equal(recoveryPlan.legacyGapFallback, true);
|
||
assert.equal(recoveryPlan.dirtyReason, "legacy-gap");
|
||
assert.equal(recoveryPlan.pendingRepairFromFloor, 5);
|
||
assert.deepEqual(recoveryPlan.backendDeleteHashes, ["hash_1"]);
|
||
assert.deepEqual(recoveryPlan.replayRequiredNodeIds, []);
|
||
}
|
||
|
||
async function testReverseJournalRecoveryPlanAggregatesDeletesAndReplay() {
|
||
const recoveryPlan = buildReverseJournalRecoveryPlan(
|
||
[
|
||
{
|
||
processedRange: [8, 9],
|
||
vectorDelta: {
|
||
insertedHashes: ["hash_new"],
|
||
removedHashes: ["hash_removed"],
|
||
replacedMappings: [
|
||
{
|
||
nodeId: "node-1",
|
||
previousHash: "hash_old",
|
||
nextHash: "hash_new",
|
||
},
|
||
],
|
||
touchedNodeIds: ["node-1"],
|
||
replayRequiredNodeIds: ["node-2"],
|
||
backendDeleteHashes: ["hash_backend"],
|
||
},
|
||
},
|
||
{
|
||
processedRange: [4, 6],
|
||
vectorDelta: {
|
||
insertedHashes: ["hash_other"],
|
||
removedHashes: [],
|
||
replacedMappings: [],
|
||
touchedNodeIds: ["node-3"],
|
||
replayRequiredNodeIds: ["node-3"],
|
||
backendDeleteHashes: [],
|
||
},
|
||
},
|
||
],
|
||
6,
|
||
);
|
||
|
||
assert.equal(recoveryPlan.legacyGapFallback, false);
|
||
assert.equal(recoveryPlan.dirtyReason, "history-recovery-replay");
|
||
assert.equal(recoveryPlan.pendingRepairFromFloor, 4);
|
||
assert.deepEqual(recoveryPlan.backendDeleteHashes.sort(), [
|
||
"hash_backend",
|
||
"hash_new",
|
||
"hash_old",
|
||
"hash_other",
|
||
"hash_removed",
|
||
]);
|
||
assert.deepEqual(recoveryPlan.replayRequiredNodeIds.sort(), [
|
||
"node-1",
|
||
"node-2",
|
||
"node-3",
|
||
]);
|
||
assert.deepEqual(recoveryPlan.touchedNodeIds.sort(), ["node-1", "node-3"]);
|
||
}
|
||
|
||
async function testReverseJournalRollbackStateFormsReplayClosure() {
|
||
const before = normalizeGraphRuntimeState(createEmptyGraph(), "chat-replay");
|
||
const after = normalizeGraphRuntimeState(createEmptyGraph(), "chat-replay");
|
||
const stableNode = createNode({
|
||
type: "event",
|
||
seq: 1,
|
||
fields: {
|
||
title: "稳定节点",
|
||
summary: "稳定摘要",
|
||
participants: "Alice",
|
||
status: "stable",
|
||
},
|
||
});
|
||
stableNode.id = "node-stable";
|
||
const touchedBefore = createNode({
|
||
type: "event",
|
||
seq: 2,
|
||
fields: {
|
||
title: "回滚前节点",
|
||
summary: "旧摘要",
|
||
participants: "Bob",
|
||
status: "old",
|
||
},
|
||
});
|
||
touchedBefore.id = "node-touched";
|
||
const touchedAfter = createNode({
|
||
type: "event",
|
||
seq: 5,
|
||
fields: {
|
||
title: "回滚后节点",
|
||
summary: "新摘要",
|
||
participants: "Bob",
|
||
status: "updated",
|
||
},
|
||
});
|
||
touchedAfter.id = "node-touched";
|
||
const appendedNode = createNode({
|
||
type: "event",
|
||
seq: 6,
|
||
fields: {
|
||
title: "新增节点",
|
||
summary: "新增摘要",
|
||
participants: "Cara",
|
||
status: "new",
|
||
},
|
||
});
|
||
appendedNode.id = "node-appended";
|
||
addNode(before, stableNode);
|
||
addNode(before, touchedBefore);
|
||
addNode(after, stableNode);
|
||
addNode(after, touchedAfter);
|
||
addNode(after, appendedNode);
|
||
|
||
before.historyState.lastProcessedAssistantFloor = 3;
|
||
before.historyState.processedMessageHashes = {
|
||
0: "h0",
|
||
1: "h1",
|
||
2: "h2",
|
||
3: "h3",
|
||
};
|
||
before.historyState.extractionCount = 1;
|
||
before.vectorIndexState.hashToNodeId = {
|
||
hash_stable: stableNode.id,
|
||
hash_old: touchedBefore.id,
|
||
};
|
||
before.vectorIndexState.nodeToHash = {
|
||
[stableNode.id]: "hash_stable",
|
||
[touchedBefore.id]: "hash_old",
|
||
};
|
||
|
||
after.historyState.lastProcessedAssistantFloor = 6;
|
||
after.historyState.processedMessageHashes = {
|
||
0: "h0",
|
||
1: "h1",
|
||
2: "h2",
|
||
3: "h3",
|
||
4: "h4",
|
||
5: "h5",
|
||
6: "h6",
|
||
};
|
||
after.historyState.extractionCount = 2;
|
||
after.vectorIndexState.hashToNodeId = {
|
||
hash_stable: stableNode.id,
|
||
hash_new: touchedAfter.id,
|
||
hash_added: appendedNode.id,
|
||
};
|
||
after.vectorIndexState.nodeToHash = {
|
||
[stableNode.id]: "hash_stable",
|
||
[touchedAfter.id]: "hash_new",
|
||
[appendedNode.id]: "hash_added",
|
||
};
|
||
after.vectorIndexState.replayRequiredNodeIds = [appendedNode.id];
|
||
|
||
const journal = createBatchJournalEntry(before, after, {
|
||
processedRange: [4, 6],
|
||
extractionCountBefore: before.historyState.extractionCount,
|
||
});
|
||
|
||
const runtimeGraph = normalizeGraphRuntimeState(
|
||
JSON.parse(JSON.stringify(after)),
|
||
"chat-replay",
|
||
);
|
||
rollbackBatch(runtimeGraph, journal);
|
||
|
||
assert.deepEqual(runtimeGraph.nodes.map((node) => node.id).sort(), [
|
||
stableNode.id,
|
||
touchedBefore.id,
|
||
]);
|
||
assert.deepEqual(runtimeGraph.vectorIndexState.hashToNodeId, {
|
||
hash_stable: stableNode.id,
|
||
hash_old: touchedBefore.id,
|
||
});
|
||
assert.deepEqual(runtimeGraph.vectorIndexState.nodeToHash, {
|
||
[stableNode.id]: "hash_stable",
|
||
[touchedBefore.id]: "hash_old",
|
||
});
|
||
assert.equal(runtimeGraph.historyState.lastProcessedAssistantFloor, 3);
|
||
|
||
const recoveryPlan = buildReverseJournalRecoveryPlan([journal], 4);
|
||
runtimeGraph.vectorIndexState.replayRequiredNodeIds = [stableNode.id];
|
||
runtimeGraph.vectorIndexState.dirty = false;
|
||
runtimeGraph.vectorIndexState.dirtyReason = "";
|
||
runtimeGraph.vectorIndexState.pendingRepairFromFloor = null;
|
||
|
||
const replayRequiredNodeIds = new Set(
|
||
runtimeGraph.vectorIndexState.replayRequiredNodeIds,
|
||
);
|
||
for (const nodeId of recoveryPlan.replayRequiredNodeIds) {
|
||
replayRequiredNodeIds.add(nodeId);
|
||
}
|
||
runtimeGraph.vectorIndexState.replayRequiredNodeIds = [
|
||
...replayRequiredNodeIds,
|
||
];
|
||
runtimeGraph.vectorIndexState.dirty = true;
|
||
runtimeGraph.vectorIndexState.dirtyReason =
|
||
recoveryPlan.dirtyReason ||
|
||
runtimeGraph.vectorIndexState.dirtyReason ||
|
||
"history-recovery-replay";
|
||
runtimeGraph.vectorIndexState.pendingRepairFromFloor =
|
||
recoveryPlan.pendingRepairFromFloor;
|
||
runtimeGraph.vectorIndexState.lastWarning = recoveryPlan.legacyGapFallback
|
||
? "历史恢复检测到 legacy-gap,向量索引需按受影响后缀修复"
|
||
: "历史恢复后需要修复受影响后缀的向量索引";
|
||
|
||
assert.deepEqual(
|
||
runtimeGraph.vectorIndexState.replayRequiredNodeIds.sort(),
|
||
[appendedNode.id, stableNode.id, touchedBefore.id].sort(),
|
||
);
|
||
assert.equal(runtimeGraph.vectorIndexState.pendingRepairFromFloor, 4);
|
||
assert.equal(
|
||
runtimeGraph.vectorIndexState.dirtyReason,
|
||
"history-recovery-replay",
|
||
);
|
||
assert.equal(
|
||
runtimeGraph.vectorIndexState.lastWarning,
|
||
"历史恢复后需要修复受影响后缀的向量索引",
|
||
);
|
||
assert.deepEqual(runtimeGraph.vectorIndexState.hashToNodeId, {
|
||
hash_stable: stableNode.id,
|
||
hash_old: touchedBefore.id,
|
||
});
|
||
assert.deepEqual(runtimeGraph.vectorIndexState.nodeToHash, {
|
||
[stableNode.id]: "hash_stable",
|
||
[touchedBefore.id]: "hash_old",
|
||
});
|
||
}
|
||
|
||
async function testReverseJournalRecoveryPlanMixedLegacyAndCurrentRetainsRepairSet() {
|
||
const recoveryPlan = buildReverseJournalRecoveryPlan(
|
||
[
|
||
{
|
||
processedRange: [10, 12],
|
||
vectorDelta: {
|
||
insertedHashes: ["hash-current"],
|
||
removedHashes: ["hash-removed"],
|
||
replacedMappings: [
|
||
{
|
||
nodeId: "node-current",
|
||
previousHash: "hash-prev",
|
||
nextHash: "hash-current",
|
||
},
|
||
],
|
||
touchedNodeIds: ["node-current"],
|
||
replayRequiredNodeIds: ["node-extra"],
|
||
backendDeleteHashes: ["hash-backend"],
|
||
},
|
||
},
|
||
{
|
||
processedRange: [7, 9],
|
||
vectorDelta: {
|
||
insertedHashes: ["hash-legacy"],
|
||
},
|
||
},
|
||
],
|
||
9,
|
||
);
|
||
|
||
assert.equal(recoveryPlan.legacyGapFallback, true);
|
||
assert.equal(recoveryPlan.dirtyReason, "legacy-gap");
|
||
assert.equal(recoveryPlan.pendingRepairFromFloor, 7);
|
||
assert.deepEqual(recoveryPlan.replayRequiredNodeIds.sort(), [
|
||
"node-current",
|
||
"node-extra",
|
||
]);
|
||
assert.deepEqual(recoveryPlan.touchedNodeIds, ["node-current"]);
|
||
assert.deepEqual(recoveryPlan.backendDeleteHashes.sort(), [
|
||
"hash-backend",
|
||
"hash-current",
|
||
"hash-legacy",
|
||
"hash-prev",
|
||
"hash-removed",
|
||
]);
|
||
}
|
||
|
||
async function testBatchStatusStructuralPartialRemainsRecoverable() {
|
||
const harness = await createBatchStageHarness();
|
||
const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result;
|
||
harness.currentGraph = {
|
||
historyState: { extractionCount: 0 },
|
||
vectorIndexState: {},
|
||
};
|
||
harness.ensureCurrentGraphRuntimeState = () => {
|
||
harness.currentGraph.historyState ||= {};
|
||
harness.currentGraph.vectorIndexState ||= {};
|
||
};
|
||
harness.compressAll = async () => {
|
||
throw new Error("compression down");
|
||
};
|
||
harness.syncVectorState = async () => ({
|
||
insertedHashes: ["hash-ok"],
|
||
stats: { pending: 0 },
|
||
});
|
||
|
||
const batchStatus = createBatchStatusSkeleton({
|
||
processedRange: [2, 4],
|
||
extractionCountBefore: 0,
|
||
});
|
||
const effects = await handleExtractionSuccess(
|
||
{ newNodeIds: ["node-1"] },
|
||
4,
|
||
{
|
||
enableConsolidation: false,
|
||
enableSynopsis: false,
|
||
enableReflection: false,
|
||
enableSleepCycle: false,
|
||
synopsisEveryN: 1,
|
||
reflectEveryN: 1,
|
||
sleepEveryN: 1,
|
||
},
|
||
undefined,
|
||
batchStatus,
|
||
);
|
||
|
||
assert.equal(effects.batchStatus.stages.core.outcome, "success");
|
||
assert.equal(effects.batchStatus.stages.structural.outcome, "partial");
|
||
assert.equal(effects.batchStatus.stages.finalize.outcome, "success");
|
||
assert.equal(effects.batchStatus.outcome, "partial");
|
||
assert.equal(effects.batchStatus.completed, true);
|
||
assert.equal(effects.batchStatus.consistency, "weak");
|
||
assert.match(effects.batchStatus.warnings[0], /压缩阶段失败/);
|
||
}
|
||
|
||
async function testBatchStatusSemanticFailureDoesNotHideCoreSuccess() {
|
||
const harness = await createBatchStageHarness();
|
||
const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result;
|
||
harness.currentGraph = {
|
||
historyState: { extractionCount: 0 },
|
||
vectorIndexState: {},
|
||
};
|
||
harness.ensureCurrentGraphRuntimeState = () => {
|
||
harness.currentGraph.historyState ||= {};
|
||
harness.currentGraph.vectorIndexState ||= {};
|
||
};
|
||
harness.generateSynopsis = async () => {
|
||
throw new Error("semantic down");
|
||
};
|
||
harness.syncVectorState = async () => ({
|
||
insertedHashes: [],
|
||
stats: { pending: 0 },
|
||
});
|
||
|
||
const batchStatus = createBatchStatusSkeleton({
|
||
processedRange: [5, 5],
|
||
extractionCountBefore: 0,
|
||
});
|
||
const effects = await handleExtractionSuccess(
|
||
{ newNodeIds: ["node-2"] },
|
||
5,
|
||
{
|
||
enableConsolidation: false,
|
||
enableSynopsis: true,
|
||
enableReflection: false,
|
||
enableSleepCycle: false,
|
||
synopsisEveryN: 1,
|
||
reflectEveryN: 1,
|
||
sleepEveryN: 1,
|
||
},
|
||
undefined,
|
||
batchStatus,
|
||
);
|
||
|
||
assert.equal(effects.batchStatus.stages.core.outcome, "success");
|
||
assert.equal(effects.batchStatus.stages.semantic.outcome, "failed");
|
||
assert.equal(effects.batchStatus.stages.finalize.outcome, "success");
|
||
assert.equal(effects.batchStatus.outcome, "failed");
|
||
assert.equal(effects.batchStatus.completed, true);
|
||
assert.match(effects.batchStatus.errors[0], /概要生成失败/);
|
||
}
|
||
|
||
async function testBatchStatusFinalizeFailureIsNotCompleteSuccess() {
|
||
const harness = await createBatchStageHarness();
|
||
const { createBatchStatusSkeleton, handleExtractionSuccess } = harness.result;
|
||
harness.currentGraph = {
|
||
historyState: { extractionCount: 0 },
|
||
vectorIndexState: {},
|
||
};
|
||
harness.ensureCurrentGraphRuntimeState = () => {
|
||
harness.currentGraph.historyState ||= {};
|
||
harness.currentGraph.vectorIndexState ||= {};
|
||
};
|
||
harness.syncVectorState = async () => ({
|
||
insertedHashes: [],
|
||
stats: { pending: 1 },
|
||
error: "vector finalize down",
|
||
});
|
||
|
||
const batchStatus = createBatchStatusSkeleton({
|
||
processedRange: [6, 7],
|
||
extractionCountBefore: 0,
|
||
});
|
||
const effects = await handleExtractionSuccess(
|
||
{ newNodeIds: ["node-3"] },
|
||
7,
|
||
{
|
||
enableConsolidation: false,
|
||
enableSynopsis: false,
|
||
enableReflection: false,
|
||
enableSleepCycle: false,
|
||
synopsisEveryN: 1,
|
||
reflectEveryN: 1,
|
||
sleepEveryN: 1,
|
||
},
|
||
undefined,
|
||
batchStatus,
|
||
);
|
||
|
||
assert.equal(effects.batchStatus.stages.core.outcome, "success");
|
||
assert.equal(effects.batchStatus.stages.finalize.outcome, "failed");
|
||
assert.equal(effects.batchStatus.outcome, "failed");
|
||
assert.equal(effects.batchStatus.completed, false);
|
||
assert.equal(effects.batchStatus.consistency, "weak");
|
||
assert.equal(effects.vectorError, "vector finalize down");
|
||
}
|
||
|
||
async function testProcessedHistoryAdvanceRequiresCompleteStrongSuccess() {
|
||
const harness = await createBatchStageHarness();
|
||
const {
|
||
createBatchStatusSkeleton,
|
||
finalizeBatchStatus,
|
||
setBatchStageOutcome,
|
||
shouldAdvanceProcessedHistory,
|
||
} = harness.result;
|
||
|
||
const structuralPartial = createBatchStatusSkeleton({
|
||
processedRange: [2, 4],
|
||
extractionCountBefore: 0,
|
||
});
|
||
setBatchStageOutcome(structuralPartial, "core", "success");
|
||
setBatchStageOutcome(
|
||
structuralPartial,
|
||
"structural",
|
||
"partial",
|
||
"compression down",
|
||
);
|
||
setBatchStageOutcome(structuralPartial, "finalize", "success");
|
||
finalizeBatchStatus(structuralPartial);
|
||
assert.equal(structuralPartial.completed, true);
|
||
assert.equal(structuralPartial.outcome, "partial");
|
||
assert.equal(structuralPartial.consistency, "weak");
|
||
assert.equal(shouldAdvanceProcessedHistory(structuralPartial), false);
|
||
|
||
const semanticFailed = createBatchStatusSkeleton({
|
||
processedRange: [5, 5],
|
||
extractionCountBefore: 0,
|
||
});
|
||
setBatchStageOutcome(semanticFailed, "core", "success");
|
||
setBatchStageOutcome(semanticFailed, "semantic", "failed", "semantic down");
|
||
setBatchStageOutcome(semanticFailed, "finalize", "success");
|
||
finalizeBatchStatus(semanticFailed);
|
||
assert.equal(semanticFailed.completed, true);
|
||
assert.equal(semanticFailed.outcome, "failed");
|
||
assert.equal(semanticFailed.consistency, "strong");
|
||
assert.equal(shouldAdvanceProcessedHistory(semanticFailed), false);
|
||
|
||
const fullSuccess = createBatchStatusSkeleton({
|
||
processedRange: [8, 9],
|
||
extractionCountBefore: 0,
|
||
});
|
||
setBatchStageOutcome(fullSuccess, "core", "success");
|
||
setBatchStageOutcome(fullSuccess, "structural", "success");
|
||
setBatchStageOutcome(fullSuccess, "semantic", "success");
|
||
setBatchStageOutcome(fullSuccess, "finalize", "success");
|
||
finalizeBatchStatus(fullSuccess);
|
||
assert.equal(fullSuccess.completed, true);
|
||
assert.equal(fullSuccess.outcome, "success");
|
||
assert.equal(fullSuccess.consistency, "strong");
|
||
assert.equal(shouldAdvanceProcessedHistory(fullSuccess), true);
|
||
}
|
||
|
||
async function testGenerationRecallTransactionDedupesDoubleHookBySameKey() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "同一轮输入" }];
|
||
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
await harness.result.onBeforeCombinePrompts();
|
||
|
||
assert.equal(harness.runRecallCalls.length, 1);
|
||
assert.equal(harness.runRecallCalls[0].hookName, "GENERATION_AFTER_COMMANDS");
|
||
}
|
||
|
||
async function testGenerationRecallTransactionDedupesReverseHookOrder() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "逆序同轮输入" }];
|
||
|
||
await harness.result.onBeforeCombinePrompts();
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
|
||
assert.equal(harness.runRecallCalls.length, 1);
|
||
assert.equal(
|
||
harness.runRecallCalls[0].hookName,
|
||
"GENERATE_BEFORE_COMBINE_PROMPTS",
|
||
);
|
||
}
|
||
|
||
async function testGenerationRecallHistoryModesUseSameBindingAcrossHooks() {
|
||
for (const generationType of ["continue", "regenerate", "swipe"]) {
|
||
const harness = await createGenerationRecallHarness();
|
||
const userMessage = `历史输入-${generationType}`;
|
||
harness.chat = [
|
||
{ is_user: true, mes: userMessage },
|
||
{ is_user: false, mes: "assistant-tail" },
|
||
];
|
||
|
||
await harness.result.onGenerationAfterCommands(generationType, {}, false);
|
||
await harness.result.onBeforeCombinePrompts();
|
||
|
||
assert.equal(
|
||
harness.runRecallCalls.length,
|
||
1,
|
||
`${generationType} 应只执行一次召回`,
|
||
);
|
||
assert.equal(
|
||
harness.runRecallCalls[0].hookName,
|
||
"GENERATION_AFTER_COMMANDS",
|
||
);
|
||
assert.equal(harness.runRecallCalls[0].targetUserMessageIndex, 0);
|
||
assert.equal(harness.runRecallCalls[0].overrideUserMessage, userMessage);
|
||
}
|
||
}
|
||
|
||
async function testGenerationRecallFrozenBindingSurvivesCrossHookInputDrift() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "稳定输入-A" }];
|
||
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
harness.chat = [{ is_user: true, mes: "稳定输入-B" }];
|
||
await harness.result.onBeforeCombinePrompts();
|
||
|
||
assert.equal(harness.runRecallCalls.length, 1);
|
||
assert.equal(harness.runRecallCalls[0].overrideUserMessage, "稳定输入-A");
|
||
}
|
||
|
||
async function testGenerationRecallSkipsUntilTargetUserFloorAvailable() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: false, mes: "assistant-only" }];
|
||
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
assert.equal(harness.runRecallCalls.length, 0);
|
||
|
||
harness.chat = [{ is_user: true, mes: "补齐 user 楼层" }];
|
||
await harness.result.onBeforeCombinePrompts();
|
||
assert.equal(harness.runRecallCalls.length, 1);
|
||
assert.equal(
|
||
harness.runRecallCalls[0].hookName,
|
||
"GENERATE_BEFORE_COMBINE_PROMPTS",
|
||
);
|
||
}
|
||
|
||
async function testGenerationRecallBeforeCombineCanUseProvisionalSendIntentBinding() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: false, mes: "assistant-tail" }];
|
||
harness.__sendTextareaValue = "发送前输入";
|
||
harness.result.pendingRecallSendIntent = {
|
||
text: "发送前输入",
|
||
hash: "hash-send-intent",
|
||
at: Date.now(),
|
||
};
|
||
|
||
await harness.result.onBeforeCombinePrompts();
|
||
|
||
assert.equal(harness.runRecallCalls.length, 1);
|
||
assert.equal(
|
||
harness.runRecallCalls[0].hookName,
|
||
"GENERATE_BEFORE_COMBINE_PROMPTS",
|
||
);
|
||
assert.equal(harness.runRecallCalls[0].overrideUserMessage, "发送前输入");
|
||
assert.equal(harness.runRecallCalls[0].targetUserMessageIndex, null);
|
||
}
|
||
|
||
async function testGenerationRecallHostLifecycleSnapshotSurvivesTextareaClearWithoutDomIntent() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: false, mes: "assistant-tail" }];
|
||
harness.__sendTextareaValue = "宿主冻结输入";
|
||
|
||
const frozenSnapshot = harness.result.freezeHostGenerationInputSnapshot(
|
||
harness.__sendTextareaValue,
|
||
);
|
||
harness.__sendTextareaValue = "";
|
||
|
||
await harness.result.onGenerationAfterCommands(
|
||
"normal",
|
||
{ frozenInputSnapshot: frozenSnapshot },
|
||
false,
|
||
);
|
||
await harness.result.onBeforeCombinePrompts();
|
||
|
||
assert.equal(harness.runRecallCalls.length, 1);
|
||
assert.equal(
|
||
harness.runRecallCalls[0].hookName,
|
||
"GENERATE_BEFORE_COMBINE_PROMPTS",
|
||
);
|
||
assert.equal(harness.runRecallCalls[0].overrideUserMessage, "宿主冻结输入");
|
||
assert.equal(harness.runRecallCalls[0].overrideSourceLabel, "宿主发送快照");
|
||
assert.equal(harness.runRecallCalls[0].targetUserMessageIndex, null);
|
||
assert.deepEqual(harness.result.getPendingHostGenerationInputSnapshot(), {
|
||
text: "",
|
||
hash: "",
|
||
at: 0,
|
||
source: "",
|
||
messageId: null,
|
||
});
|
||
}
|
||
|
||
async function testGenerationRecallAfterCommandsStillSkipsWithoutStableUserFloor() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: false, mes: "assistant-tail" }];
|
||
harness.__sendTextareaValue = "发送前输入";
|
||
harness.result.pendingRecallSendIntent = {
|
||
text: "发送前输入",
|
||
hash: "hash-send-intent",
|
||
at: Date.now(),
|
||
};
|
||
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
|
||
assert.equal(
|
||
harness.runRecallCalls.length,
|
||
0,
|
||
"after-commands 在缺失稳定 user floor 时应继续跳过,避免错误楼层绑定",
|
||
);
|
||
}
|
||
|
||
async function testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "同 key 连续生成" }];
|
||
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
|
||
assert.equal(harness.runRecallCalls.length, 2);
|
||
assert.equal(
|
||
harness.runRecallCalls[0].recallKey,
|
||
harness.runRecallCalls[1].recallKey,
|
||
);
|
||
}
|
||
|
||
async function testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "同 key 重复生成" }];
|
||
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
const transaction = [
|
||
...harness.result.generationRecallTransactions.values(),
|
||
][0];
|
||
transaction.updatedAt = Date.now() - 5000;
|
||
harness.result.generationRecallTransactions.set(transaction.id, transaction);
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
|
||
assert.equal(harness.runRecallCalls.length, 2);
|
||
}
|
||
|
||
async function testGenerationRecallBeforeCombineRunsStandalone() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "仅 before combine" }];
|
||
|
||
await harness.result.onBeforeCombinePrompts();
|
||
|
||
assert.equal(harness.runRecallCalls.length, 1);
|
||
assert.equal(
|
||
harness.runRecallCalls[0].hookName,
|
||
"GENERATE_BEFORE_COMBINE_PROMPTS",
|
||
);
|
||
}
|
||
|
||
async function testGenerationRecallDifferentKeyCanRunAgain() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "第一条" }];
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
|
||
harness.chat = [{ is_user: true, mes: "第二条" }];
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
|
||
assert.equal(harness.runRecallCalls.length, 2);
|
||
assert.notEqual(
|
||
harness.runRecallCalls[0].recallKey,
|
||
harness.runRecallCalls[1].recallKey,
|
||
);
|
||
}
|
||
|
||
async function testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "同一条但本次跳过" }];
|
||
harness.runRecall = async (options = {}) => {
|
||
harness.runRecallCalls.push({ ...options });
|
||
return {
|
||
status: "skipped",
|
||
didRecall: false,
|
||
ok: false,
|
||
reason: "测试跳过",
|
||
};
|
||
};
|
||
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
await harness.result.onBeforeCombinePrompts();
|
||
|
||
assert.equal(harness.runRecallCalls.length, 1);
|
||
assert.equal(harness.result.generationRecallTransactions.size, 1);
|
||
const transaction = [
|
||
...harness.result.generationRecallTransactions.values(),
|
||
][0];
|
||
assert.equal(transaction.hookStates.GENERATION_AFTER_COMMANDS, "skipped");
|
||
}
|
||
|
||
async function testGenerationRecallAppliesFinalInjectionOncePerTransaction() {
|
||
const harness = await createGenerationRecallHarness();
|
||
harness.chat = [{ is_user: true, mes: "同一轮仅一次最终注入" }];
|
||
|
||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||
await harness.result.onBeforeCombinePrompts();
|
||
|
||
assert.equal(harness.applyFinalCalls.length, 1);
|
||
assert.equal(harness.applyFinalCalls[0].generationType, "normal");
|
||
}
|
||
|
||
async function testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphReadable() {
|
||
const { runRecallController } = await import("../recall-controller.js");
|
||
const statuses = [];
|
||
const graph = normalizeGraphRuntimeState(createEmptyGraph(), "chat-main");
|
||
graph.nodes.push(
|
||
createNode("event", {
|
||
title: "旧事件",
|
||
summary: "来自 runtime graph",
|
||
}),
|
||
);
|
||
|
||
const runtime = {
|
||
getIsRecalling: () => false,
|
||
abortRecallStageWithReason() {},
|
||
waitForActiveRecallToSettle: async () => ({ settled: true }),
|
||
getCurrentGraph: () => graph,
|
||
getSettings: () => ({
|
||
enabled: true,
|
||
recallEnabled: true,
|
||
recallLlmContextMessages: 4,
|
||
}),
|
||
isGraphReadable: () => false,
|
||
isGraphReadableForRecall: () => true,
|
||
getGraphMutationBlockReason: () => "召回已暂停:正在加载 IndexedDB 图谱。",
|
||
setLastRecallStatus: (...args) => {
|
||
statuses.push(args);
|
||
},
|
||
isGraphMetadataWriteAllowed: () => false,
|
||
recoverHistoryIfNeeded: async () => {
|
||
throw new Error("loading 期间不应触发历史恢复");
|
||
},
|
||
getContext: () => ({
|
||
chat: [{ is_user: true, mes: "发送前输入" }],
|
||
}),
|
||
nextRecallRunSequence: () => 1,
|
||
setIsRecalling() {},
|
||
beginStageAbortController: () => ({
|
||
signal: { aborted: false, addEventListener() {} },
|
||
abort() {},
|
||
}),
|
||
createAbortError: (message) => new Error(message),
|
||
ensureVectorReadyIfNeeded: async () => {},
|
||
clampInt,
|
||
resolveRecallInput: () => ({
|
||
userMessage: "发送前输入",
|
||
recentMessages: ["[user]: 发送前输入"],
|
||
source: "send-intent",
|
||
sourceLabel: "发送意图",
|
||
generationType: "normal",
|
||
targetUserMessageIndex: null,
|
||
}),
|
||
console,
|
||
getRecallHookLabel: () => "发送前拦截",
|
||
retrieve: async ({ graph: passedGraph, userMessage }) => {
|
||
assert.equal(passedGraph, graph);
|
||
assert.equal(userMessage, "发送前输入");
|
||
return {
|
||
stats: { recallCount: 1, coreCount: 1 },
|
||
selectedNodeIds: [graph.nodes[0].id],
|
||
meta: {
|
||
retrieval: {
|
||
vectorHits: 1,
|
||
diffusionHits: 0,
|
||
llm: { status: "disabled", candidatePool: 0 },
|
||
},
|
||
},
|
||
};
|
||
},
|
||
getEmbeddingConfig: () => null,
|
||
getSchema: () => schema,
|
||
buildRecallRetrieveOptions: () => ({}),
|
||
applyRecallInjection: (_settings, recallInput) => ({
|
||
injectionText: `注入:${recallInput.userMessage}`,
|
||
}),
|
||
createRecallInputRecord,
|
||
createRecallRunResult,
|
||
isAbortError: () => false,
|
||
toastr: {
|
||
warning() {},
|
||
error() {},
|
||
},
|
||
finishStageAbortController() {},
|
||
getActiveRecallPromise: () => null,
|
||
setActiveRecallPromise() {},
|
||
setPendingRecallSendIntent() {},
|
||
refreshPanelLiveState() {},
|
||
};
|
||
|
||
const result = await runRecallController(runtime, {
|
||
hookName: "GENERATE_BEFORE_COMBINE_PROMPTS",
|
||
});
|
||
|
||
assert.equal(result.status, "completed");
|
||
assert.equal(result.didRecall, true);
|
||
assert.equal(result.injectionText, "注入:发送前输入");
|
||
assert.equal(
|
||
statuses.some(([title]) => title === "等待图谱加载"),
|
||
false,
|
||
"runtime graph 可读时不应再被 loading 门禁误判为等待图谱加载",
|
||
);
|
||
}
|
||
|
||
async function testPersistentRecallDataLayerLifecycleAndCompatibility() {
|
||
const chat = [
|
||
{ is_user: true, mes: "u0" },
|
||
{ is_user: false, mes: "a1" },
|
||
{ is_user: true, mes: "u2" },
|
||
];
|
||
|
||
const record = buildPersistedRecallRecord({
|
||
injectionText: "fresh-memory",
|
||
selectedNodeIds: ["n1", "n2"],
|
||
recallInput: "u2",
|
||
recallSource: "chat-last-user",
|
||
hookName: "GENERATION_AFTER_COMMANDS",
|
||
tokenEstimate: 24,
|
||
manuallyEdited: false,
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
});
|
||
assert.equal(writePersistedRecallToUserMessage(chat, 2, record), true);
|
||
|
||
const loaded = readPersistedRecallFromUserMessage(chat, 2);
|
||
assert.ok(loaded);
|
||
assert.equal(loaded.injectionText, "fresh-memory");
|
||
assert.equal(loaded.generationCount, 0);
|
||
assert.equal(loaded.manuallyEdited, false);
|
||
|
||
chat[2].mes = "u2 edited";
|
||
assert.equal(
|
||
readPersistedRecallFromUserMessage(chat, 2)?.injectionText,
|
||
"fresh-memory",
|
||
);
|
||
|
||
const bumped = bumpPersistedRecallGenerationCount(chat, 2);
|
||
assert.equal(bumped?.generationCount, 1);
|
||
|
||
const edited = markPersistedRecallManualEdit(
|
||
chat,
|
||
2,
|
||
true,
|
||
"2026-01-01T00:00:01.000Z",
|
||
);
|
||
assert.equal(edited?.manuallyEdited, true);
|
||
assert.equal(edited?.updatedAt, "2026-01-01T00:00:01.000Z");
|
||
|
||
const overwrite = buildPersistedRecallRecord(
|
||
{
|
||
injectionText: "system-rerecall",
|
||
selectedNodeIds: ["n3"],
|
||
recallInput: "u2 edited",
|
||
recallSource: "message-floor-rerecall",
|
||
hookName: "MESSAGE_RECALL_BADGE_RERUN",
|
||
tokenEstimate: 30,
|
||
manuallyEdited: false,
|
||
nowIso: "2026-01-01T00:00:02.000Z",
|
||
},
|
||
readPersistedRecallFromUserMessage(chat, 2),
|
||
);
|
||
assert.equal(writePersistedRecallToUserMessage(chat, 2, overwrite), true);
|
||
const overwritten = readPersistedRecallFromUserMessage(chat, 2);
|
||
assert.equal(overwritten?.manuallyEdited, false);
|
||
assert.equal(overwritten?.injectionText, "system-rerecall");
|
||
|
||
assert.equal(removePersistedRecallFromUserMessage(chat, 2), true);
|
||
assert.equal(readPersistedRecallFromUserMessage(chat, 2), null);
|
||
assert.equal(
|
||
readPersistedRecallFromUserMessage([{ is_user: true, mes: "legacy" }], 0),
|
||
null,
|
||
);
|
||
}
|
||
|
||
async function testPersistentRecallSourceResolutionAndTargetRouting() {
|
||
const chat = [
|
||
{ is_user: true, mes: "u0" },
|
||
{ is_user: false, mes: "a1" },
|
||
{ is_user: true, mes: "u2" },
|
||
{ is_user: false, mes: "a3" },
|
||
];
|
||
|
||
assert.equal(
|
||
resolveGenerationTargetUserMessageIndex(chat, { generationType: "normal" }),
|
||
null,
|
||
);
|
||
assert.equal(
|
||
resolveGenerationTargetUserMessageIndex(chat, {
|
||
generationType: "continue",
|
||
}),
|
||
2,
|
||
);
|
||
|
||
const withTailUser = [...chat, { is_user: true, mes: "u4" }];
|
||
assert.equal(
|
||
resolveGenerationTargetUserMessageIndex(withTailUser, {
|
||
generationType: "normal",
|
||
}),
|
||
4,
|
||
);
|
||
|
||
const freshWins = resolveFinalRecallInjectionSource({
|
||
freshRecallResult: {
|
||
status: "completed",
|
||
didRecall: true,
|
||
injectionText: "fresh",
|
||
},
|
||
persistedRecord: { injectionText: "persisted" },
|
||
});
|
||
assert.equal(freshWins.source, "fresh");
|
||
assert.equal(freshWins.injectionText, "fresh");
|
||
|
||
const fallback = resolveFinalRecallInjectionSource({
|
||
freshRecallResult: {
|
||
status: "skipped",
|
||
didRecall: false,
|
||
injectionText: "",
|
||
},
|
||
persistedRecord: { injectionText: "persisted" },
|
||
});
|
||
assert.equal(fallback.source, "persisted");
|
||
assert.equal(fallback.injectionText, "persisted");
|
||
}
|
||
|
||
async function testRecallSubGraphAndDataLayerEntryPoints() {
|
||
// Sub-graph build test (pure function, no DOM needed)
|
||
const { buildRecallSubGraph } = await import("../recall-message-ui.js");
|
||
|
||
const graph = {
|
||
nodes: [
|
||
{ id: "n1", type: "character", name: "赵管家", importance: 7 },
|
||
{ id: "n2", type: "event", name: "喂食", importance: 5 },
|
||
{
|
||
id: "n3",
|
||
type: "location",
|
||
name: "厨房",
|
||
importance: 3,
|
||
archived: true,
|
||
},
|
||
{ id: "n4", type: "thread", name: "主线", importance: 8 },
|
||
],
|
||
edges: [
|
||
{ fromId: "n1", toId: "n2", strength: 0.8, relation: "related" },
|
||
{ fromId: "n2", toId: "n3", strength: 0.5, relation: "located" },
|
||
{ fromId: "n1", toId: "n4", strength: 0.6, relation: "participates" },
|
||
],
|
||
};
|
||
|
||
const sub1 = buildRecallSubGraph(graph, ["n1", "n2"]);
|
||
assert.equal(sub1.nodes.length, 2);
|
||
assert.equal(sub1.edges.length, 1);
|
||
assert.equal(sub1.edges[0].fromId, "n1");
|
||
|
||
// archived node should be excluded
|
||
const sub2 = buildRecallSubGraph(graph, ["n1", "n3"]);
|
||
assert.equal(sub2.nodes.length, 1);
|
||
assert.equal(sub2.edges.length, 0);
|
||
|
||
// empty/null safety
|
||
assert.equal(buildRecallSubGraph(null, ["n1"]).nodes.length, 0);
|
||
assert.equal(buildRecallSubGraph(graph, null).nodes.length, 0);
|
||
assert.equal(buildRecallSubGraph(graph, []).nodes.length, 0);
|
||
|
||
// Data layer: edit and delete still work
|
||
const chat = [
|
||
{
|
||
is_user: true,
|
||
mes: "u0",
|
||
extra: {
|
||
bme_recall: {
|
||
version: 1,
|
||
injectionText: "test",
|
||
selectedNodeIds: ["n1"],
|
||
generationCount: 0,
|
||
manuallyEdited: false,
|
||
createdAt: "2026-01-01T00:00:00Z",
|
||
updatedAt: "2026-01-01T00:00:00Z",
|
||
recallInput: "u0",
|
||
recallSource: "test",
|
||
hookName: "TEST",
|
||
tokenEstimate: 4,
|
||
},
|
||
},
|
||
},
|
||
];
|
||
assert.ok(readPersistedRecallFromUserMessage(chat, 0));
|
||
assert.equal(removePersistedRecallFromUserMessage(chat, 0), true);
|
||
assert.equal(readPersistedRecallFromUserMessage(chat, 0), null);
|
||
}
|
||
|
||
async function testRerollUsesBatchBoundaryRollbackAndPersistsState() {
|
||
const harness = await createRerollHarness();
|
||
harness.chat = [
|
||
{ is_user: true, mes: "u1" },
|
||
{ is_user: false, mes: "a1" },
|
||
{ is_user: true, mes: "u2" },
|
||
{ is_user: false, mes: "a2" },
|
||
{ is_user: true, mes: "u3" },
|
||
{ is_user: false, mes: "a3" },
|
||
];
|
||
harness.currentGraph = {
|
||
historyState: {
|
||
lastProcessedAssistantFloor: 5,
|
||
processedMessageHashes: {
|
||
1: "hash-1",
|
||
3: "hash-3",
|
||
5: "hash-5",
|
||
},
|
||
},
|
||
vectorIndexState: {
|
||
collectionId: "col-1",
|
||
},
|
||
batchJournal: [{ id: "journal-1" }],
|
||
lastProcessedSeq: 5,
|
||
};
|
||
harness.postRollbackGraph = {
|
||
historyState: {
|
||
lastProcessedAssistantFloor: 1,
|
||
processedMessageHashes: {
|
||
1: "hash-1",
|
||
3: "stale-hash",
|
||
},
|
||
},
|
||
vectorIndexState: {
|
||
collectionId: "col-1",
|
||
},
|
||
batchJournal: [],
|
||
lastProcessedSeq: 1,
|
||
};
|
||
harness.findJournalRecoveryPointImpl = () => ({
|
||
path: "reverse-journal",
|
||
affectedBatchCount: 1,
|
||
affectedJournals: [{ id: "journal-1" }],
|
||
});
|
||
harness.buildReverseJournalRecoveryPlanImpl = () => ({
|
||
backendDeleteHashes: ["hash-old"],
|
||
replayRequiredNodeIds: ["node-1"],
|
||
pendingRepairFromFloor: 2,
|
||
legacyGapFallback: false,
|
||
dirtyReason: "history-recovery-replay",
|
||
});
|
||
|
||
const result = await harness.result.onReroll({ fromFloor: 3 });
|
||
|
||
assert.equal(result.success, true);
|
||
assert.equal(result.rollbackPerformed, true);
|
||
assert.equal(result.recoveryPath, "reverse-journal");
|
||
assert.equal(result.effectiveFromFloor, 2);
|
||
assert.equal(harness.rollbackAffectedJournalsCalls.length, 1);
|
||
assert.equal(harness.deletedHashesCalls.length, 1);
|
||
assert.equal(harness.prepareVectorStateCalls.length, 1);
|
||
assert.equal(harness.prepareVectorStateCalls[0][2].skipBackendPurge, true);
|
||
assert.equal(harness.saveGraphToChatCalls, 1);
|
||
assert.equal(harness.refreshPanelCalls, 2);
|
||
assert.equal(harness.clearInjectionCalls, 1);
|
||
assert.equal(harness.onManualExtractCalls, 1);
|
||
assert.equal(
|
||
harness.currentGraph.historyState.processedMessageHashes[3],
|
||
undefined,
|
||
);
|
||
assert.equal(harness.lastExtractedItems.length, 0);
|
||
}
|
||
|
||
async function testRerollRejectsMissingRecoveryPoint() {
|
||
const harness = await createRerollHarness();
|
||
harness.chat = [
|
||
{ is_user: true, mes: "u1" },
|
||
{ is_user: false, mes: "a1" },
|
||
{ is_user: true, mes: "u2" },
|
||
{ is_user: false, mes: "a2" },
|
||
];
|
||
harness.currentGraph = {
|
||
historyState: {
|
||
lastProcessedAssistantFloor: 3,
|
||
processedMessageHashes: {
|
||
1: "hash-1",
|
||
3: "hash-3",
|
||
},
|
||
},
|
||
vectorIndexState: {
|
||
collectionId: "col-1",
|
||
},
|
||
batchJournal: [],
|
||
lastProcessedSeq: 3,
|
||
};
|
||
|
||
const result = await harness.result.onReroll({ fromFloor: 3 });
|
||
|
||
assert.equal(result.success, false);
|
||
assert.equal(result.recoveryPath, "unavailable");
|
||
assert.equal(harness.onManualExtractCalls, 0);
|
||
assert.equal(harness.saveGraphToChatCalls, 0);
|
||
}
|
||
|
||
async function testRerollFallsBackToDirectExtractForUnprocessedFloor() {
|
||
const harness = await createRerollHarness();
|
||
harness.chat = [
|
||
{ is_user: true, mes: "u1" },
|
||
{ is_user: false, mes: "a1" },
|
||
{ is_user: true, mes: "u2" },
|
||
{ is_user: false, mes: "a2" },
|
||
];
|
||
harness.currentGraph = {
|
||
historyState: {
|
||
lastProcessedAssistantFloor: 1,
|
||
processedMessageHashes: {
|
||
1: "hash-1",
|
||
},
|
||
},
|
||
vectorIndexState: {
|
||
collectionId: "col-1",
|
||
},
|
||
batchJournal: [],
|
||
lastProcessedSeq: 1,
|
||
};
|
||
|
||
const result = await harness.result.onReroll({ fromFloor: 3 });
|
||
|
||
assert.equal(result.success, true);
|
||
assert.equal(result.rollbackPerformed, false);
|
||
assert.equal(result.recoveryPath, "direct-extract");
|
||
assert.equal(result.effectiveFromFloor, 2);
|
||
assert.equal(harness.onManualExtractCalls, 1);
|
||
assert.equal(harness.saveGraphToChatCalls, 0);
|
||
}
|
||
|
||
async function testLlmDebugSnapshotRedactsSecretsBeforeStorage() {
|
||
const originalFetch = globalThis.fetch;
|
||
const previousSettings = JSON.parse(
|
||
JSON.stringify(extensionsApi.extension_settings.st_bme || {}),
|
||
);
|
||
delete globalThis.__stBmeRuntimeDebugState;
|
||
extensionsApi.extension_settings.st_bme = {
|
||
...previousSettings,
|
||
llmApiUrl: "https://example.com/v1",
|
||
llmApiKey: "sk-secret-redaction",
|
||
llmModel: "gpt-test",
|
||
timeoutMs: 1234,
|
||
};
|
||
|
||
globalThis.fetch = async () =>
|
||
new Response(
|
||
JSON.stringify({
|
||
choices: [
|
||
{
|
||
message: {
|
||
content: '{"ok":true}',
|
||
},
|
||
finish_reason: "stop",
|
||
},
|
||
],
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
},
|
||
);
|
||
|
||
try {
|
||
const result = await llm.callLLMForJSON({
|
||
systemPrompt: "system",
|
||
userPrompt: "user",
|
||
maxRetries: 0,
|
||
requestSource: "test:redaction",
|
||
});
|
||
assert.deepEqual(result, { ok: true });
|
||
|
||
const snapshot =
|
||
globalThis.__stBmeRuntimeDebugState?.taskLlmRequests?.["test:redaction"];
|
||
assert.ok(snapshot);
|
||
assert.equal(snapshot.redacted, true);
|
||
const serialized = JSON.stringify(snapshot);
|
||
assert.doesNotMatch(serialized, /sk-secret-redaction/);
|
||
assert.match(serialized, /\[REDACTED\]/);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
extensionsApi.extension_settings.st_bme = previousSettings;
|
||
}
|
||
}
|
||
|
||
async function testEmbeddingUsesConfigTimeoutInsteadOfDefault() {
|
||
const originalFetch = globalThis.fetch;
|
||
const originalSetTimeout = globalThis.setTimeout;
|
||
const originalClearTimeout = globalThis.clearTimeout;
|
||
let capturedDelay = null;
|
||
|
||
globalThis.setTimeout = (fn, delay, ...args) => {
|
||
capturedDelay = delay;
|
||
return originalSetTimeout(fn, 0, ...args);
|
||
};
|
||
globalThis.clearTimeout = originalClearTimeout;
|
||
globalThis.fetch = async (_url, options = {}) =>
|
||
await new Promise((resolve, reject) => {
|
||
options.signal?.addEventListener(
|
||
"abort",
|
||
() => reject(options.signal.reason),
|
||
{ once: true },
|
||
);
|
||
});
|
||
|
||
try {
|
||
await assert.rejects(
|
||
embedding.embedText("timeout test", {
|
||
apiUrl: "https://example.com/v1",
|
||
model: "text-embedding-test",
|
||
timeoutMs: 7,
|
||
}),
|
||
/Embedding 请求超时/,
|
||
);
|
||
assert.equal(capturedDelay, 7);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
globalThis.setTimeout = originalSetTimeout;
|
||
globalThis.clearTimeout = originalClearTimeout;
|
||
}
|
||
}
|
||
|
||
async function testLlmOutputRegexCleansResponseBeforeJsonParse() {
|
||
const originalFetch = globalThis.fetch;
|
||
const previousSettings = JSON.parse(
|
||
JSON.stringify(extensionsApi.extension_settings.st_bme || {}),
|
||
);
|
||
delete globalThis.__stBmeRuntimeDebugState;
|
||
|
||
const taskProfiles = createDefaultTaskProfiles();
|
||
taskProfiles.extract.profiles[0].regex = {
|
||
...taskProfiles.extract.profiles[0].regex,
|
||
enabled: true,
|
||
inheritStRegex: false,
|
||
stages: {
|
||
...taskProfiles.extract.profiles[0].regex.stages,
|
||
"output.rawResponse": true,
|
||
"output.beforeParse": true,
|
||
},
|
||
localRules: [
|
||
{
|
||
id: "strip-prefix",
|
||
script_name: "strip-prefix",
|
||
enabled: true,
|
||
find_regex: "/^NOTE:\\s*/g",
|
||
replace_string: "",
|
||
trim_strings: [],
|
||
source: {
|
||
ai_output: true,
|
||
},
|
||
destination: {
|
||
prompt: true,
|
||
display: false,
|
||
},
|
||
},
|
||
{
|
||
id: "strip-suffix",
|
||
script_name: "strip-suffix",
|
||
enabled: true,
|
||
find_regex: "/\\s*END$/g",
|
||
replace_string: "",
|
||
trim_strings: [],
|
||
source: {
|
||
ai_output: true,
|
||
},
|
||
destination: {
|
||
prompt: true,
|
||
display: false,
|
||
},
|
||
},
|
||
],
|
||
};
|
||
|
||
extensionsApi.extension_settings.st_bme = {
|
||
...previousSettings,
|
||
llmApiUrl: "https://example.com/v1",
|
||
llmApiKey: "sk-secret-redaction",
|
||
llmModel: "gpt-test",
|
||
taskProfilesVersion: 1,
|
||
taskProfiles,
|
||
};
|
||
|
||
globalThis.fetch = async () =>
|
||
new Response(
|
||
JSON.stringify({
|
||
choices: [
|
||
{
|
||
message: {
|
||
content: 'NOTE: {"ok":true} END',
|
||
},
|
||
finish_reason: "stop",
|
||
},
|
||
],
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
},
|
||
);
|
||
|
||
try {
|
||
const result = await llm.callLLMForJSON({
|
||
systemPrompt: "system",
|
||
userPrompt: "user",
|
||
maxRetries: 0,
|
||
taskType: "extract",
|
||
requestSource: "test:output-regex",
|
||
});
|
||
assert.deepEqual(result, { ok: true });
|
||
|
||
const snapshot =
|
||
globalThis.__stBmeRuntimeDebugState?.taskLlmRequests?.extract;
|
||
assert.ok(snapshot);
|
||
assert.equal(snapshot.responseCleaning?.applied, true);
|
||
assert.equal(snapshot.responseCleaning?.changed, true);
|
||
assert.deepEqual(
|
||
snapshot.responseCleaning?.stages?.map((entry) => entry.stage),
|
||
["output.rawResponse", "output.beforeParse"],
|
||
);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
extensionsApi.extension_settings.st_bme = previousSettings;
|
||
}
|
||
}
|
||
|
||
await testCompressorMigratesEdgesToCompressedNode();
|
||
await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure();
|
||
await testExtractorFailsOnUnknownOperation();
|
||
await testConsolidatorMergeUpdatesSeqRange();
|
||
await testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing();
|
||
await testBatchJournalVectorDeltaCapturesRecoveryFields();
|
||
await testReverseJournalRecoveryPlanLegacyFallback();
|
||
await testReverseJournalRecoveryPlanAggregatesDeletesAndReplay();
|
||
await testReverseJournalRollbackStateFormsReplayClosure();
|
||
await testReverseJournalRecoveryPlanMixedLegacyAndCurrentRetainsRepairSet();
|
||
await testBatchStatusStructuralPartialRemainsRecoverable();
|
||
await testBatchStatusSemanticFailureDoesNotHideCoreSuccess();
|
||
await testBatchStatusFinalizeFailureIsNotCompleteSuccess();
|
||
await testProcessedHistoryAdvanceRequiresCompleteStrongSuccess();
|
||
await testGenerationRecallTransactionDedupesDoubleHookBySameKey();
|
||
await testGenerationRecallTransactionDedupesReverseHookOrder();
|
||
await testGenerationRecallHistoryModesUseSameBindingAcrossHooks();
|
||
await testGenerationRecallFrozenBindingSurvivesCrossHookInputDrift();
|
||
await testGenerationRecallSkipsUntilTargetUserFloorAvailable();
|
||
await testGenerationRecallBeforeCombineCanUseProvisionalSendIntentBinding();
|
||
await testGenerationRecallHostLifecycleSnapshotSurvivesTextareaClearWithoutDomIntent();
|
||
await testGenerationRecallAfterCommandsStillSkipsWithoutStableUserFloor();
|
||
await testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration();
|
||
await testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow();
|
||
await testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphReadable();
|
||
await testGenerationRecallBeforeCombineRunsStandalone();
|
||
await testGenerationRecallDifferentKeyCanRunAgain();
|
||
await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine();
|
||
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
|
||
await testPersistentRecallDataLayerLifecycleAndCompatibility();
|
||
await testPersistentRecallSourceResolutionAndTargetRouting();
|
||
await testRecallCardMountsOnStandardUserMessageDom();
|
||
await testRecallCardSkipsMountWithoutStableMessageIndex();
|
||
await testRecallCardDelayedDomInsertionEventuallyRenders();
|
||
await testRecallCardDoesNotMountOnNonUserFloor();
|
||
await testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates();
|
||
await testRecallCardExpandedContentRerendersAfterRecordUpdate();
|
||
await testRecallCardUserTextRefreshesWithoutCardRecreate();
|
||
await testRecallSubGraphAndDataLayerEntryPoints();
|
||
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
|
||
await testRerollRejectsMissingRecoveryPoint();
|
||
await testRerollFallsBackToDirectExtractForUnprocessedFloor();
|
||
await testLlmDebugSnapshotRedactsSecretsBeforeStorage();
|
||
await testEmbeddingUsesConfigTimeoutInsteadOfDefault();
|
||
await testLlmOutputRegexCleansResponseBeforeJsonParse();
|
||
|
||
console.log("p0-regressions tests passed");
|