diff --git a/host/event-binding.js b/host/event-binding.js index 16b3dc5..64c0dad 100644 --- a/host/event-binding.js +++ b/host/event-binding.js @@ -442,6 +442,10 @@ export function onMessageEditedController(runtime, messageId, meta = null) { runtime.refreshPersistedRecallMessageUi?.(); return; } + const parsedMessageId = Number(messageId); + if (Number.isFinite(parsedMessageId)) { + runtime.removeMessageRecallRecord?.(Math.floor(parsedMessageId)); + } runtime.invalidateRecallAfterHistoryMutation("消息已编辑"); runtime.scheduleHistoryMutationRecheck("message-edited", messageId, meta); runtime.refreshPersistedRecallMessageUi?.(); diff --git a/index.js b/index.js index 0e98abe..1066c6f 100644 --- a/index.js +++ b/index.js @@ -22469,6 +22469,7 @@ function onMessageEdited(messageId, meta = null) { { invalidateRecallAfterHistoryMutation, isMvuExtraAnalysisGuardActive, + removeMessageRecallRecord, refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, scheduleHistoryMutationRecheck, }, diff --git a/retrieval/recall-controller.js b/retrieval/recall-controller.js index 3e30582..be93d12 100644 --- a/retrieval/recall-controller.js +++ b/retrieval/recall-controller.js @@ -93,7 +93,6 @@ function buildPersistedRecallReuseResult(record = {}) { function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { const generationType = String(recallInput?.generationType || "normal").trim() || "normal"; - if (generationType === "normal") return null; const targetUserMessageIndex = Number.isFinite(recallInput?.targetUserMessageIndex) ? Math.floor(Number(recallInput.targetUserMessageIndex)) @@ -119,6 +118,16 @@ function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { const currentRecallInputText = normalizeText(recallInput?.userMessage || ""); const recordRecallInput = normalizeText(record?.recallInput || ""); const boundUserFloorText = normalizeText(record?.boundUserFloorText || ""); + const recallSource = String(recallInput?.source || "").trim(); + const activeInputSources = new Set([ + "send-intent", + "generation-started-send-intent", + "generation-started-textarea", + "host-generation-lifecycle", + "textarea-live", + "planner-handoff", + ]); + const isActiveInputSource = activeInputSources.has(recallSource); const matchesBoundUserFloor = Boolean( currentUserFloorText && @@ -135,10 +144,38 @@ function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { boundUserFloorText && currentUserFloorText === boundUserFloorText, ); + const boundUserFloorMismatch = Boolean( + boundUserFloorText && currentUserFloorText !== boundUserFloorText, + ); + if (boundUserFloorMismatch) return null; - if (record.authoritativeInputUsed) { - if (!matchesBoundUserFloor) return null; - } else if (!matchesRecallInput && !matchesCurrentUserFloor) { + const matchesPersistedRecord = record.authoritativeInputUsed + ? matchesBoundUserFloor + : matchesRecallInput || matchesCurrentUserFloor; + const canReuseUnboundTargetRecord = Boolean( + currentUserFloorText && + !boundUserFloorText && + !isActiveInputSource && + String(record?.injectionText || "").trim(), + ); + const userFloorSources = new Set([ + "chat-last-user", + "chat-latest-user", + "chat-tail-user", + "message-sent", + "persisted-user-floor", + ]); + const canTrustUserFloorRecord = Boolean( + !isActiveInputSource && + !boundUserFloorText && + (generationType !== "normal" || userFloorSources.has(recallSource)), + ); + + if ( + !matchesPersistedRecord && + !canReuseUnboundTargetRecord && + !canTrustUserFloorRecord + ) { return null; } diff --git a/tests/message-updated-lightweight.mjs b/tests/message-updated-lightweight.mjs index 7670566..066c548 100644 --- a/tests/message-updated-lightweight.mjs +++ b/tests/message-updated-lightweight.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { + onMessageEditedController, onMessageUpdatedController, registerCoreEventHooksController, } from "../host/event-binding.js"; @@ -38,6 +39,38 @@ import { assert.equal(ignored?.detail?.reason, "lightweight-refresh-only"); } +{ + let removedMessageIndex = null; + let invalidated = 0; + let rechecked = 0; + let refreshed = 0; + + onMessageEditedController( + { + isMvuExtraAnalysisGuardActive: () => false, + removeMessageRecallRecord(messageIndex) { + removedMessageIndex = messageIndex; + }, + invalidateRecallAfterHistoryMutation() { + invalidated += 1; + }, + scheduleHistoryMutationRecheck() { + rechecked += 1; + }, + refreshPersistedRecallMessageUi() { + refreshed += 1; + }, + }, + 9, + { source: "unit-test" }, + ); + + assert.equal(removedMessageIndex, 9); + assert.equal(invalidated, 1); + assert.equal(rechecked, 1); + assert.equal(refreshed, 1); +} + { const bindings = []; const runtime = { diff --git a/tests/recall-reroll-reuse.mjs b/tests/recall-reroll-reuse.mjs index 976cfb8..21279a3 100644 --- a/tests/recall-reroll-reuse.mjs +++ b/tests/recall-reroll-reuse.mjs @@ -432,8 +432,210 @@ assert.equal( console.log(" ✓ runRecallController reuses persisted record on regenerate"); +const normalTypedReuseChat = [ + { is_user: true, mes: "重 Roll 但宿主仍标 normal" }, + { is_user: false, mes: "上一条回复。", is_system: false }, +]; +const normalTypedReuseRecord = buildPersistedRecallRecord({ + injectionText: "注入:重 Roll 但宿主仍标 normal", + selectedNodeIds: ["node-normal-type"], + recallInput: "旧捕获文本", + recallSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 5, + manuallyEdited: false, + boundUserFloorText: "重 Roll 但宿主仍标 normal", +}); +writePersistedRecallToUserMessage(normalTypedReuseChat, 0, normalTypedReuseRecord); + +let normalTypedRetrieveCalled = false; +const normalTypedReuseRuntime = { + ...rerollRuntime, + getContext: () => ({ chat: normalTypedReuseChat, chatId: "chat-normal-typed-reroll" }), + retrieve: async () => { + normalTypedRetrieveCalled = true; + return { + injectionText: "不应出现的新召回", + selectedNodeIds: ["node-new"], + }; + }, +}; + +const normalTypedReuseResult = await runRecallController(normalTypedReuseRuntime, { + overrideUserMessage: "重 Roll 但宿主仍标 normal", + generationType: "normal", + targetUserMessageIndex: 0, + overrideSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + deliveryMode: "immediate", +}); + +assert.equal( + normalTypedReuseResult.reason, + "persisted-user-floor-reused", + "normal-typed reroll should still reuse target user-floor recall", +); +assert.equal( + normalTypedRetrieveCalled, + false, + "normal-typed reroll should not call retrieve when target user floor has recall", +); + +console.log(" ✓ runRecallController reuses persisted record when host reports reroll as normal"); + +const legacyUnboundReuseChat = [ + { is_user: true, mes: "旧记录没有绑定楼层" }, + { is_user: false, mes: "上一条回复。", is_system: false }, +]; +const legacyUnboundRecord = buildPersistedRecallRecord({ + injectionText: "注入:旧记录没有绑定楼层", + selectedNodeIds: ["node-legacy-unbound"], + recallInput: "历史旧输入", + recallSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 5, + manuallyEdited: false, +}); +writePersistedRecallToUserMessage(legacyUnboundReuseChat, 0, legacyUnboundRecord); + +let legacyUnboundRetrieveCalled = false; +const legacyUnboundRuntime = { + ...rerollRuntime, + getContext: () => ({ chat: legacyUnboundReuseChat, chatId: "chat-legacy-unbound" }), + retrieve: async () => { + legacyUnboundRetrieveCalled = true; + return { + injectionText: "不应出现的旧记录新召回", + selectedNodeIds: ["node-new"], + }; + }, +}; + +const legacyUnboundResult = await runRecallController(legacyUnboundRuntime, { + overrideUserMessage: "旧记录没有绑定楼层", + generationType: "normal", + targetUserMessageIndex: 0, + overrideSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + deliveryMode: "immediate", +}); + +assert.equal( + legacyUnboundResult.reason, + "persisted-user-floor-reused", + "legacy unbound user-floor recall should be reused for normal-typed history generation", +); +assert.equal( + legacyUnboundRetrieveCalled, + false, + "legacy unbound user-floor recall should not call retrieve", +); + +console.log(" ✓ runRecallController reuses legacy unbound user-floor recall"); + +const activeInputUnboundChat = [ + { is_user: true, mes: "主动新输入不应复用旧召回" }, + { is_user: false, mes: "上一条回复。", is_system: false }, +]; +const activeInputUnboundRecord = buildPersistedRecallRecord({ + injectionText: "旧注入:主动新输入不应复用旧召回", + selectedNodeIds: ["node-active-old"], + recallInput: "旧输入", + recallSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 5, + manuallyEdited: false, +}); +writePersistedRecallToUserMessage(activeInputUnboundChat, 0, activeInputUnboundRecord); + +let activeInputRetrieveCalled = false; +const activeInputRuntime = { + ...rerollRuntime, + getContext: () => ({ chat: activeInputUnboundChat, chatId: "chat-active-input" }), + retrieve: async () => { + activeInputRetrieveCalled = true; + return { + injectionText: "新召回:主动新输入不应复用旧召回", + selectedNodeIds: ["node-active-new"], + }; + }, +}; + +const activeInputResult = await runRecallController(activeInputRuntime, { + overrideUserMessage: "主动新输入不应复用旧召回", + generationType: "normal", + targetUserMessageIndex: 0, + overrideSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", + deliveryMode: "immediate", +}); + +assert.equal( + activeInputRetrieveCalled, + true, + "active send-intent input should not reuse an unbound stale record", +); +assert.equal( + activeInputResult.injectionText, + "新召回:主动新输入不应复用旧召回", + "active send-intent input should use the fresh recall result", +); + +console.log(" ✓ runRecallController does not reuse unbound record for active input"); + +const mismatchedBoundChat = [ + { is_user: true, mes: "已经编辑过的新楼层" }, + { is_user: false, mes: "上一条回复。", is_system: false }, +]; +const mismatchedBoundRecord = buildPersistedRecallRecord({ + injectionText: "旧注入:已经编辑过的新楼层", + selectedNodeIds: ["node-mismatch-old"], + recallInput: "已经编辑过的新楼层", + recallSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 5, + manuallyEdited: false, + boundUserFloorText: "编辑前的旧楼层", +}); +writePersistedRecallToUserMessage(mismatchedBoundChat, 0, mismatchedBoundRecord); + +let mismatchedBoundRetrieveCalled = false; +const mismatchedBoundRuntime = { + ...rerollRuntime, + getContext: () => ({ chat: mismatchedBoundChat, chatId: "chat-bound-mismatch" }), + retrieve: async () => { + mismatchedBoundRetrieveCalled = true; + return { + injectionText: "新召回:已经编辑过的新楼层", + selectedNodeIds: ["node-mismatch-new"], + }; + }, +}; + +const mismatchedBoundResult = await runRecallController(mismatchedBoundRuntime, { + overrideUserMessage: "已经编辑过的新楼层", + generationType: "normal", + targetUserMessageIndex: 0, + overrideSource: "chat-last-user", + hookName: "GENERATION_AFTER_COMMANDS", + deliveryMode: "immediate", +}); + +assert.equal( + mismatchedBoundRetrieveCalled, + true, + "bound user-floor mismatch should force a fresh recall", +); +assert.equal( + mismatchedBoundResult.injectionText, + "新召回:已经编辑过的新楼层", + "bound user-floor mismatch should not reuse stale persisted recall", +); + +console.log(" ✓ runRecallController does not reuse record when bound user floor mismatches"); + // ═══════════════════════════════════════════════════════════════ -// 4. runRecallController: regenerate with empty recallInput does NOT reuse +// 4. runRecallController: regenerate with empty recallInput reuses user-floor record // ═══════════════════════════════════════════════════════════════ const noReuseChat = [ @@ -494,11 +696,16 @@ const noReuseResult = await runRecallController(noReuseRuntime, { assert.equal(noReuseResult.status, "completed", "no-reuse should complete"); assert.equal( noReuseRetrieveCalled, - true, - "retrieve() SHOULD be called when persisted record has empty recallInput", + false, + "retrieve() should NOT be called when target user floor has a persisted recall record", +); +assert.equal( + noReuseResult.reason, + "persisted-user-floor-reused", + "empty recallInput legacy records should still be reused from the user floor", ); -console.log(" ✓ runRecallController does NOT reuse record with empty recallInput"); +console.log(" ✓ runRecallController reuses user-floor record with empty recallInput"); // ═══════════════════════════════════════════════════════════════ console.log("recall-reroll-reuse tests passed");