Files
ST-Bionic-Memory-Ecology/tests/recall-reroll-reuse.mjs
Youzini-afk aa3ee1e408 fix: reroll 时复用已有召回记录而非重新触发召回
- rebindRecallRecordToNewUserMessage: 从 frozenRecallOptions 补全 recallInput/boundUserFloorText/authoritativeInputUsed
- ensurePersistedRecallRecordForGeneration: already-up-to-date 守卫增加 recallInput 非空检查
- 新增 tests/recall-reroll-reuse.mjs 回归测试
2026-04-23 01:19:29 +08:00

354 lines
13 KiB
JavaScript

// ST-BME: regression tests — reroll should reuse persisted recall record
//
// Covers:
// 1. ensurePersistedRecallRecordForGeneration re-writes when existing record
// has same injectionText/nodeIds but empty recallInput
// 2. resolveReusablePersistedRecallRecord (inside runRecallController) reuses
// a persisted record when recallInput matches the user floor text
// 3. End-to-end: regenerate does NOT call retrieve when a valid persisted
// record exists
import assert from "node:assert/strict";
import {
buildPersistedRecallRecord,
readPersistedRecallFromUserMessage,
writePersistedRecallToUserMessage,
BME_RECALL_EXTRA_KEY,
} from "../retrieval/recall-persistence.js";
import { runRecallController } from "../retrieval/recall-controller.js";
import { createGenerationRecallHarness } from "./helpers/generation-recall-harness.mjs";
import {
normalizeRecallInputText,
createRecallRunResult,
createRecallInputRecord,
isFreshRecallInputRecord,
} from "../ui/ui-status.js";
import { defaultSettings } from "../runtime/settings-defaults.js";
// ═══════════════════════════════════════════════════════════════
// 1. ensurePersistedRecallRecordForGeneration: empty recallInput override
// ═══════════════════════════════════════════════════════════════
const harness = await createGenerationRecallHarness({ realApplyFinal: true });
// Prime settings
Object.assign(harness.settings, {
...defaultSettings,
enabled: true,
recallEnabled: true,
});
// Set up chat: user + assistant
harness.chat = [
{ is_user: true, mes: "去摩耶山看夜景" },
{ is_user: false, mes: "好的,我们出发吧。", is_system: false },
];
// Pre-write a persisted record with EMPTY recallInput (simulates old bug)
const emptyRecallInputRecord = buildPersistedRecallRecord({
injectionText: "注入:去摩耶山看夜景",
selectedNodeIds: ["node-test-1"],
recallInput: "",
recallSource: "chat-tail-user",
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 5,
manuallyEdited: false,
});
writePersistedRecallToUserMessage(harness.chat, 0, emptyRecallInputRecord);
// Verify the record is written with empty recallInput
const beforeRecord = readPersistedRecallFromUserMessage(harness.chat, 0);
assert.ok(beforeRecord, "persisted record should exist before ensure");
assert.equal(beforeRecord.recallInput, "", "recallInput should be empty before fix");
assert.equal(
beforeRecord.injectionText,
"注入:去摩耶山看夜景",
"injectionText should match",
);
// Build a mock recall result with the same injectionText
const mockRecallResult = {
status: "completed",
didRecall: true,
ok: true,
injectionText: "注入:去摩耶山看夜景",
selectedNodeIds: ["node-test-1"],
source: "chat-last-user",
sourceLabel: "历史最后用户楼层",
hookName: "GENERATION_AFTER_COMMANDS",
authoritativeInputUsed: false,
boundUserFloorText: "去摩耶山看夜景",
};
// Build frozen recall options with overrideUserMessage
const frozenRecallOptions = {
generationType: "regenerate",
targetUserMessageIndex: 0,
overrideUserMessage: "去摩耶山看夜景",
overrideSource: "chat-last-user",
overrideSourceLabel: "历史最后用户楼层",
lockedSource: "chat-last-user",
lockedSourceLabel: "历史最后用户楼层",
authoritativeInputUsed: false,
boundUserFloorText: "去摩耶山看夜景",
};
// Call ensurePersistedRecallRecordForGeneration
const ensureResult = harness.result.ensurePersistedRecallRecordForGeneration({
generationType: "regenerate",
recallResult: mockRecallResult,
transaction: { frozenRecallOptions },
recallOptions: frozenRecallOptions,
hookName: "GENERATION_AFTER_COMMANDS",
});
// After fix: the record should be overwritten because existing recallInput is empty
const afterRecord = readPersistedRecallFromUserMessage(harness.chat, 0);
assert.ok(afterRecord, "persisted record should still exist after ensure");
assert.equal(
afterRecord.recallInput,
"去摩耶山看夜景",
"recallInput should now be populated after ensure overwrites empty-recallInput record",
);
assert.equal(
afterRecord.boundUserFloorText,
"去摩耶山看夜景",
"boundUserFloorText should be populated",
);
console.log(" ✓ ensurePersistedRecallRecordForGeneration overwrites record with empty recallInput");
// ═══════════════════════════════════════════════════════════════
// 2. ensurePersistedRecallRecordForGeneration: populated recallInput skip
// ═══════════════════════════════════════════════════════════════
// Now the record has proper recallInput — calling ensure again should skip
const ensureResult2 = harness.result.ensurePersistedRecallRecordForGeneration({
generationType: "regenerate",
recallResult: mockRecallResult,
transaction: { frozenRecallOptions },
recallOptions: frozenRecallOptions,
hookName: "GENERATION_AFTER_COMMANDS",
});
assert.equal(
ensureResult2.reason,
"already-up-to-date",
"should skip when recallInput is already populated",
);
console.log(" ✓ ensurePersistedRecallRecordForGeneration skips when recallInput is populated");
// ═══════════════════════════════════════════════════════════════
// 3. runRecallController: regenerate reuses persisted record
// ═══════════════════════════════════════════════════════════════
// Set up a fresh chat with a properly persisted recall record
const rerollChat = [
{ is_user: true, mes: "明日去摩耶山看夜景" },
{ is_user: false, mes: "好的,明天约好了。", is_system: false },
];
const validRecord = buildPersistedRecallRecord({
injectionText: "注入:明日去摩耶山看夜景",
selectedNodeIds: ["node-a"],
recallInput: "明日去摩耶山看夜景",
recallSource: "chat-tail-user",
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 5,
manuallyEdited: false,
boundUserFloorText: "明日去摩耶山看夜景",
});
writePersistedRecallToUserMessage(rerollChat, 0, validRecord);
let retrieveCalled = false;
const rerollRuntime = {
getIsRecalling: () => false,
getCurrentGraph: () => ({ nodes: [], edges: [] }),
getSettings: () => ({
...defaultSettings,
enabled: true,
recallEnabled: true,
recallLlmContextMessages: 5,
}),
isGraphReadableForRecall: () => true,
isGraphMetadataWriteAllowed: () => true,
recoverHistoryIfNeeded: async () => true,
getContext: () => ({ chat: rerollChat, chatId: "chat-reroll" }),
nextRecallRunSequence: () => 1,
beginStageAbortController: () => ({ signal: { aborted: false } }),
finishStageAbortController: () => {},
setIsRecalling: () => {},
setActiveRecallPromise: () => {},
getActiveRecallPromise: () => null,
setLastRecallStatus: () => {},
clampInt: (v, f, mn, mx) => {
const n = Number(v);
if (!Number.isFinite(n)) return f;
return Math.min(mx, Math.max(mn, Math.trunc(n)));
},
normalizeRecallInputText,
createRecallInputRecord,
createRecallRunResult,
isFreshRecallInputRecord,
getLatestUserChatMessage: (chat = []) =>
[...chat].reverse().find((m) => m?.is_user) || null,
getLastNonSystemChatMessage: (chat = []) =>
[...chat].reverse().find((m) => !m?.is_system) || null,
getRecallUserMessageSourceLabel: (s) => s,
buildRecallRecentMessages: () => [],
readPersistedRecallFromUserMessage,
bumpPersistedRecallGenerationCount: (chat, idx) => {
// no-op in test; just return the record
return readPersistedRecallFromUserMessage(chat, idx);
},
triggerChatMetadataSave: () => {},
schedulePersistedRecallMessageUiRefresh: () => {},
refreshPanelLiveState: () => {},
ensureVectorReadyIfNeeded: async () => {},
resolveRecallInput: (chat, limit, override) => {
// Simulate resolveRecallInputController override path
const overrideText = normalizeRecallInputText(
override?.overrideUserMessage || override?.userMessage || "",
);
return {
userMessage: overrideText,
generationType: String(override?.generationType || "normal"),
targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex)
? override.targetUserMessageIndex
: null,
source: override?.overrideSource || "chat-last-user",
sourceLabel: override?.overrideSourceLabel || "历史最后用户楼层",
reason: "override-bound",
authoritativeInputUsed: Boolean(override?.authoritativeInputUsed),
boundUserFloorText: normalizeRecallInputText(
override?.boundUserFloorText || "",
),
recentMessages: [],
hookName: override?.hookName || "",
deliveryMode: "immediate",
};
},
applyRecallInjection: (_settings, _input, _recent, result) => ({
injectionText: result?.injectionText || "",
applied: true,
source: "persisted-reuse",
mode: "module-injection",
}),
retrieve: async () => {
retrieveCalled = true;
return {
injectionText: "should-not-appear",
selectedNodeIds: ["node-b"],
};
},
buildRecallRetrieveOptions: () => ({}),
getEmbeddingConfig: () => ({}),
getSchema: () => ({}),
console,
isAbortError: () => false,
toastr: { error: () => {} },
getRecallHookLabel: () => "",
setPendingRecallSendIntent: () => {},
};
// Simulate regenerate: override with the user floor text and generationType regenerate
const rerollResult = await runRecallController(rerollRuntime, {
overrideUserMessage: "明日去摩耶山看夜景",
generationType: "regenerate",
targetUserMessageIndex: 0,
overrideSource: "chat-last-user",
overrideSourceLabel: "历史最后用户楼层",
hookName: "GENERATION_AFTER_COMMANDS",
deliveryMode: "immediate",
});
assert.equal(rerollResult.status, "completed", "reroll should complete");
assert.equal(
rerollResult.reason,
"persisted-user-floor-reused",
"reroll should reuse persisted record, not run fresh recall",
);
assert.equal(
retrieveCalled,
false,
"retrieve() should NOT be called when persisted record is reused",
);
assert.equal(
rerollResult.injectionText,
"注入:明日去摩耶山看夜景",
"injection text should come from persisted record",
);
console.log(" ✓ runRecallController reuses persisted record on regenerate");
// ═══════════════════════════════════════════════════════════════
// 4. runRecallController: regenerate with empty recallInput does NOT reuse
// ═══════════════════════════════════════════════════════════════
const noReuseChat = [
{ is_user: true, mes: "去看星星" },
{ is_user: false, mes: "好的。", is_system: false },
];
const emptyInputRecord = buildPersistedRecallRecord({
injectionText: "注入:去看星星",
selectedNodeIds: ["node-c"],
recallInput: "",
recallSource: "chat-tail-user",
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 3,
manuallyEdited: false,
});
writePersistedRecallToUserMessage(noReuseChat, 0, emptyInputRecord);
let noReuseRetrieveCalled = false;
const noReuseRuntime = {
...rerollRuntime,
getContext: () => ({ chat: noReuseChat, chatId: "chat-no-reuse" }),
readPersistedRecallFromUserMessage,
retrieve: async () => {
noReuseRetrieveCalled = true;
return {
injectionText: "新召回结果",
selectedNodeIds: ["node-d"],
};
},
resolveRecallInput: (chat, limit, override) => ({
userMessage: normalizeRecallInputText(
override?.overrideUserMessage || "",
),
generationType: String(override?.generationType || "normal"),
targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex)
? override.targetUserMessageIndex
: null,
source: override?.overrideSource || "chat-last-user",
sourceLabel: override?.overrideSourceLabel || "",
reason: "override-bound",
authoritativeInputUsed: false,
boundUserFloorText: "",
recentMessages: [],
hookName: override?.hookName || "",
deliveryMode: "immediate",
}),
};
const noReuseResult = await runRecallController(noReuseRuntime, {
overrideUserMessage: "去看星星",
generationType: "regenerate",
targetUserMessageIndex: 0,
overrideSource: "chat-last-user",
hookName: "GENERATION_AFTER_COMMANDS",
deliveryMode: "immediate",
});
assert.equal(noReuseResult.status, "completed", "no-reuse should complete");
assert.equal(
noReuseRetrieveCalled,
true,
"retrieve() SHOULD be called when persisted record has empty recallInput",
);
console.log(" ✓ runRecallController does NOT reuse record with empty recallInput");
// ═══════════════════════════════════════════════════════════════
console.log("recall-reroll-reuse tests passed");