fix: restore before-combine recall gating

This commit is contained in:
Youzini-afk
2026-03-31 15:22:08 +08:00
parent 4c58121cce
commit d8710f45f1
3 changed files with 966 additions and 404 deletions

819
index.js

File diff suppressed because it is too large Load Diff

View File

@@ -19,9 +19,8 @@ export function buildRecallRecentMessagesController(
recentMessages.unshift(runtime.formatRecallContextLine(message));
}
const normalizedSynthetic = runtime.normalizeRecallInputText(
syntheticUserMessage,
);
const normalizedSynthetic =
runtime.normalizeRecallInputText(syntheticUserMessage);
if (!normalizedSynthetic) return recentMessages;
const syntheticLine = `[user]: ${normalizedSynthetic}`;
@@ -63,8 +62,12 @@ export function resolveRecallInputController(
return {
userMessage: overrideText,
generationType: String(override?.generationType || "normal"),
targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex) ? override.targetUserMessageIndex : null,
source: String(override?.source || override?.overrideSource || "override"),
targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex)
? override.targetUserMessageIndex
: null,
source: String(
override?.source || override?.overrideSource || "override",
),
sourceLabel: String(
override?.sourceLabel || override?.overrideSourceLabel || "发送前拦截",
),
@@ -155,7 +158,12 @@ export function applyRecallInjectionController(
runtime.console.log(
`[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`,
);
runtime.persistRecallInjectionRecord?.({ recallInput, result, injectionText, tokenEstimate: tokens });
runtime.persistRecallInjectionRecord?.({
recallInput,
result,
injectionText,
tokenEstimate: tokens,
});
}
const injectionTransport = runtime.applyModuleInjectionPrompt(
@@ -196,7 +204,9 @@ export function applyRecallInjectionController(
recallInput.sourceLabel,
`ctx ${recentMessages.length}`,
`vector ${retrievalMeta.vectorHits ?? 0}`,
retrievalMeta.vectorMergedHits ? `merged ${retrievalMeta.vectorMergedHits}` : "",
retrievalMeta.vectorMergedHits
? `merged ${retrievalMeta.vectorMergedHits}`
: "",
`diffusion ${retrievalMeta.diffusionHits ?? 0}`,
retrievalMeta.candidatePoolAfterDpp
? `dpp ${retrievalMeta.candidatePoolAfterDpp}`
@@ -259,7 +269,11 @@ export async function runRecallController(runtime, options = {}) {
reason: "召回功能未启用",
});
}
if (!runtime.isGraphReadable()) {
const isReadableForRecall =
typeof runtime.isGraphReadableForRecall === "function"
? runtime.isGraphReadableForRecall()
: runtime.isGraphReadable();
if (!isReadableForRecall) {
const reason = runtime.getGraphMutationBlockReason("召回");
runtime.setLastRecallStatus("等待图谱加载", reason, "warning", {
syncRuntime: true,
@@ -300,7 +314,8 @@ export async function runRecallController(runtime, options = {}) {
"abort",
() =>
recallController.abort(
options.signal.reason || runtime.createAbortError("宿主已终止生成"),
options.signal.reason ||
runtime.createAbortError("宿主已终止生成"),
),
{ once: true },
);

View File

@@ -4,10 +4,31 @@ 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,
clampFloat,
clampInt,
createBatchStageStatus,
createBatchStatusSkeleton,
@@ -16,7 +37,6 @@ import {
createRecallRunResult,
createUiStatus,
finalizeBatchStatus,
formatRecallContextLine,
getGenerationRecallHookStateFromResult,
getRecallHookLabel,
getStageNoticeDuration,
@@ -30,44 +50,6 @@ import {
setBatchStageOutcome,
shouldRunRecallForTransaction,
} from "../ui-status.js";
import {
cloneRuntimeDebugValue,
GRAPH_LOAD_STATES,
GRAPH_METADATA_KEY,
GRAPH_PERSISTENCE_META_KEY,
GRAPH_PERSISTENCE_SESSION_ID,
MODULE_NAME,
readGraphShadowSnapshot,
stampGraphPersistenceMeta,
writeChatMetadataPatch,
writeGraphShadowSnapshot,
} from "../graph-persistence.js";
import {
buildExtractionMessages,
clampRecoveryStartFloor,
getAssistantTurns,
getChatIndexForAssistantSeq,
getChatIndexForPlayableSeq,
getMinExtractableAssistantFloor,
isAssistantChatMessage,
pruneProcessedMessageHashesFromFloor,
rollbackAffectedJournals,
} from "../chat-history.js";
import {
onBeforeCombinePromptsController,
onGenerationAfterCommandsController,
} from "../event-binding.js";
import { onRerollController } from "../extraction-controller.js";
import {
buildPersistedRecallRecord,
readPersistedRecallFromUserMessage,
removePersistedRecallFromUserMessage,
resolveFinalRecallInjectionSource,
resolveGenerationTargetUserMessageIndex,
writePersistedRecallToUserMessage,
bumpPersistedRecallGenerationCount,
markPersistedRecallManualEdit,
} from "../recall-persistence.js";
const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0));
const extensionsShimSource = [
@@ -332,7 +314,9 @@ function createGenerationRecallHarness() {
onBeforeCombinePromptsController,
onGenerationAfterCommandsController,
readPersistedRecallFromUserMessage: () => null,
resolveFinalRecallInjectionSource: ({ freshRecallResult = null } = {}) => ({
resolveFinalRecallInjectionSource: ({
freshRecallResult = null,
} = {}) => ({
source: freshRecallResult?.didRecall ? "fresh" : "none",
injectionText: String(freshRecallResult?.injectionText || ""),
record: null,
@@ -342,11 +326,16 @@ function createGenerationRecallHarness() {
getSettings: () => ({}),
triggerChatMetadataSave: () => "debounced",
refreshPanelLiveState: () => {},
resolveGenerationTargetUserMessageIndex: (chat = [], { generationType } = {}) => {
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;
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;
},
};
@@ -373,8 +362,12 @@ function createGenerationRecallHarness() {
function createRerollHarness() {
return fs.readFile(indexPath, "utf8").then((source) => {
const rollbackStart = source.indexOf("async function rollbackGraphForReroll(");
const rollbackEnd = source.indexOf("async function recoverHistoryIfNeeded(");
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 (
@@ -387,7 +380,10 @@ function createRerollHarness() {
) {
throw new Error("无法从 index.js 提取 reroll 定义");
}
const snippet = [source.slice(rollbackStart, rollbackEnd), source.slice(rerollStart, rerollEnd)]
const snippet = [
source.slice(rollbackStart, rollbackEnd),
source.slice(rerollStart, rerollEnd),
]
.join("\n")
.replace(/^export\s+/gm, "");
const context = {
@@ -559,7 +555,6 @@ function pushTestOverrides(patch = {}) {
};
}
class FakeClassList {
constructor(owner) {
this.owner = owner;
@@ -567,7 +562,11 @@ class FakeClassList {
}
setFromString(value = "") {
this.tokens = new Set(String(value || "").split(/\s+/).filter(Boolean));
this.tokens = new Set(
String(value || "")
.split(/\s+/)
.filter(Boolean),
);
}
add(...tokens) {
@@ -686,7 +685,11 @@ class FakeElement {
child.parentElement = this;
child.ownerDocument = this.ownerDocument;
this.children.push(child);
this.ownerDocument?._notifyMutation({ type: "childList", target: this, addedNodes: [child] });
this.ownerDocument?._notifyMutation({
type: "childList",
target: this,
addedNodes: [child],
});
return child;
}
@@ -695,7 +698,11 @@ class FakeElement {
if (index >= 0) {
this.children.splice(index, 1);
child.parentElement = null;
this.ownerDocument?._notifyMutation({ type: "childList", target: this, removedNodes: [child] });
this.ownerDocument?._notifyMutation({
type: "childList",
target: this,
removedNodes: [child],
});
}
return child;
}
@@ -785,9 +792,12 @@ class FakeDocument {
}
const attrMatches = [...selector.matchAll(/\[([^=\]]+)="([^\]]*)"\]/g)];
const attrless = selector.replace(/\[[^\]]+\]/g, "");
const classMatches = [...attrless.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
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;
if (tagMatch && node.tagName.toLowerCase() !== tagMatch[0].toLowerCase())
return false;
for (const className of classMatches) {
if (!node.classList.contains(className)) return false;
}
@@ -813,9 +823,15 @@ class FakeDocument {
}
_querySelectorAll(selector, scopeRoot) {
const segments = String(selector || "").trim().split(/\s+/).filter(Boolean);
const segments = String(selector || "")
.trim()
.split(/\s+/)
.filter(Boolean);
const nodes = this._flatten(scopeRoot);
return nodes.filter((node) => node !== scopeRoot && this._matchesSelectorChain(node, segments));
return nodes.filter(
(node) =>
node !== scopeRoot && this._matchesSelectorChain(node, segments),
);
}
_registerObserver(observer) {
@@ -871,7 +887,11 @@ function createDomHarness(chat) {
return { document, chatRoot, MutationObserver: observerClass, chat };
}
function createMessageElement(document, messageIndex, { stableId = true, withMesBlock = true, isUser = true } = {}) {
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));
@@ -896,7 +916,10 @@ function appendLegacyBadge(document, messageElement) {
return badge;
}
async function createRecallUiHarness({ chat, graph = { nodes: [], edges: [] } } = {}) {
async function createRecallUiHarness({
chat,
graph = { nodes: [], edges: [] },
} = {}) {
const harness = createDomHarness(chat);
const previousDocument = globalThis.document;
globalThis.document = harness.document;
@@ -935,7 +958,11 @@ async function createRecallUiHarness({ chat, graph = { nodes: [], edges: [] } }
getContext: () => ({ chat }),
getSettings: () => ({ panelTheme: "crimson" }),
triggerChatMetadataSave: () => "debounced",
estimateTokens: (text = "") => String(text || "").trim().split(/\s+/).filter(Boolean).length || 1,
estimateTokens: (text = "") =>
String(text || "")
.trim()
.split(/\s+/)
.filter(Boolean).length || 1,
toastr: {
success() {},
warning() {},
@@ -975,18 +1002,38 @@ async function createRecallUiHarness({ chat, graph = { nodes: [], edges: [] } }
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" }) } },
{
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 });
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);
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();
}
@@ -994,17 +1041,34 @@ async function testRecallCardMountsOnStandardUserMessageDom() {
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" }) } },
{
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 });
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);
assert.equal(
harness.chatRoot.querySelectorAll(".bme-recall-card").length,
0,
);
} finally {
harness.restoreGlobals();
}
@@ -1012,7 +1076,17 @@ async function testRecallCardSkipsMountWithoutStableMessageIndex() {
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" }) } },
{
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 {
@@ -1025,15 +1099,26 @@ async function testRecallCardDelayedDomInsertionEventuallyRenders() {
harness.api.schedulePersistedRecallMessageUiRefresh();
await waitForTick();
const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true });
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 重复刷新");
assert.equal(
harness.chatRoot.querySelectorAll(".bme-recall-card").length,
1,
);
assert.equal(
updateCalls,
0,
"observer 先触发后不应再被旧 timeout 重复刷新",
);
} finally {
harness.restoreGlobals();
}
@@ -1041,17 +1126,34 @@ async function testRecallCardDelayedDomInsertionEventuallyRenders() {
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" }) } },
{
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 });
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);
assert.equal(
harness.chatRoot.querySelectorAll(".bme-recall-card").length,
0,
);
} finally {
harness.restoreGlobals();
}
@@ -1059,10 +1161,24 @@ async function testRecallCardDoesNotMountOnNonUserFloor() {
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" }) } },
{
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 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";
@@ -1077,8 +1193,14 @@ async function testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates() {
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(
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();
@@ -1136,14 +1258,18 @@ async function testRecallCardExpandedContentRerendersAfterRecordUpdate() {
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-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;
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);
@@ -1154,23 +1280,43 @@ async function testRecallCardExpandedContentRerendersAfterRecordUpdate() {
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" }) } },
{
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 });
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");
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");
assert.equal(
secondCard.querySelector(".bme-recall-user-text")?.textContent,
"after-user",
);
} finally {
harness.restoreGlobals();
}
@@ -2055,8 +2201,15 @@ async function testGenerationRecallHistoryModesUseSameBindingAcrossHooks() {
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.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);
}
@@ -2138,7 +2291,10 @@ async function testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration(
await harness.result.onGenerationAfterCommands("normal", {}, false);
assert.equal(harness.runRecallCalls.length, 2);
assert.equal(harness.runRecallCalls[0].recallKey, harness.runRecallCalls[1].recallKey);
assert.equal(
harness.runRecallCalls[0].recallKey,
harness.runRecallCalls[1].recallKey,
);
}
async function testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow() {
@@ -2146,7 +2302,9 @@ async function testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow() {
harness.chat = [{ is_user: true, mes: "同 key 重复生成" }];
await harness.result.onGenerationAfterCommands("normal", {}, false);
const transaction = [...harness.result.generationRecallTransactions.values()][0];
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);
@@ -2199,11 +2357,10 @@ async function testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine() {
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(harness.result.generationRecallTransactions.size, 1);
const transaction = [
...harness.result.generationRecallTransactions.values(),
][0];
assert.equal(transaction.hookStates.GENERATION_AFTER_COMMANDS, "skipped");
}
@@ -2218,6 +2375,108 @@ async function testGenerationRecallAppliesFinalInjectionOncePerTransaction() {
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" },
@@ -2244,7 +2503,10 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() {
assert.equal(loaded.manuallyEdited, false);
chat[2].mes = "u2 edited";
assert.equal(readPersistedRecallFromUserMessage(chat, 2)?.injectionText, "fresh-memory");
assert.equal(
readPersistedRecallFromUserMessage(chat, 2)?.injectionText,
"fresh-memory",
);
const bumped = bumpPersistedRecallGenerationCount(chat, 2);
assert.equal(bumped?.generationCount, 1);
@@ -2278,7 +2540,10 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() {
assert.equal(removePersistedRecallFromUserMessage(chat, 2), true);
assert.equal(readPersistedRecallFromUserMessage(chat, 2), null);
assert.equal(readPersistedRecallFromUserMessage([{ is_user: true, mes: "legacy" }], 0), null);
assert.equal(
readPersistedRecallFromUserMessage([{ is_user: true, mes: "legacy" }], 0),
null,
);
}
async function testPersistentRecallSourceResolutionAndTargetRouting() {
@@ -2289,21 +2554,42 @@ async function testPersistentRecallSourceResolutionAndTargetRouting() {
{ is_user: false, mes: "a3" },
];
assert.equal(resolveGenerationTargetUserMessageIndex(chat, { generationType: "normal" }), null);
assert.equal(resolveGenerationTargetUserMessageIndex(chat, { generationType: "continue" }), 2);
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);
assert.equal(
resolveGenerationTargetUserMessageIndex(withTailUser, {
generationType: "normal",
}),
4,
);
const freshWins = resolveFinalRecallInjectionSource({
freshRecallResult: { status: "completed", didRecall: true, injectionText: "fresh" },
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: "" },
freshRecallResult: {
status: "skipped",
didRecall: false,
injectionText: "",
},
persistedRecord: { injectionText: "persisted" },
});
assert.equal(fallback.source, "persisted");
@@ -2318,7 +2604,13 @@ async function testRecallSubGraphAndDataLayerEntryPoints() {
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: "n3",
type: "location",
name: "厨房",
importance: 3,
archived: true,
},
{ id: "n4", type: "thread", name: "主线", importance: 8 },
],
edges: [
@@ -2344,7 +2636,27 @@ async function testRecallSubGraphAndDataLayerEntryPoints() {
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 } } }];
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);
@@ -2416,7 +2728,10 @@ async function testRerollUsesBatchBoundaryRollbackAndPersistsState() {
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.currentGraph.historyState.processedMessageHashes[3],
undefined,
);
assert.equal(harness.lastExtractedItems.length, 0);
}
@@ -2704,6 +3019,7 @@ await testGenerationRecallBeforeCombineCanUseProvisionalSendIntentBinding();
await testGenerationRecallAfterCommandsStillSkipsWithoutStableUserFloor();
await testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration();
await testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow();
await testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphReadable();
await testGenerationRecallBeforeCombineRunsStandalone();
await testGenerationRecallDifferentKeyCanRunAgain();
await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine();