diff --git a/index.js b/index.js index 34e14b3..0e98abe 100644 --- a/index.js +++ b/index.js @@ -5250,6 +5250,12 @@ function persistRecallInjectionRecord({ return null; } + const targetUserFloorText = normalizeRecallInputText( + chat[resolvedTargetIndex]?.mes || "", + ); + const boundUserFloorText = normalizeRecallInputText( + recallInput?.boundUserFloorText || targetUserFloorText, + ); const record = buildPersistedRecallRecord( { injectionText, @@ -5259,6 +5265,8 @@ function persistRecallInjectionRecord({ hookName: String(recallInput?.hookName || ""), tokenEstimate, manuallyEdited: false, + authoritativeInputUsed: Boolean(recallInput?.authoritativeInputUsed), + boundUserFloorText, }, readPersistedRecallFromUserMessage(chat, resolvedTargetIndex), ); @@ -5379,11 +5387,34 @@ function ensurePersistedRecallRecordForGeneration({ chat, targetUserMessageIndex, ); + const nextAuthoritativeInputUsed = Boolean( + recallResult?.authoritativeInputUsed ?? + frozenRecallOptions?.authoritativeInputUsed ?? + recallOptions?.authoritativeInputUsed, + ); + const targetUserFloorText = normalizeRecallInputText( + chat[targetUserMessageIndex]?.mes || "", + ); + const nextBoundUserFloorText = normalizeRecallInputText( + recallResult?.boundUserFloorText || + frozenRecallOptions?.boundUserFloorText || + recallOptions?.boundUserFloorText || + targetUserFloorText || + "", + ); + const existingBoundUserFloorText = normalizeRecallInputText( + existingRecord?.boundUserFloorText || "", + ); + const existingMetadataUpToDate = + Boolean(existingRecord?.authoritativeInputUsed) === nextAuthoritativeInputUsed && + (!nextBoundUserFloorText || + existingBoundUserFloorText === nextBoundUserFloorText); if ( existingRecord && String(existingRecord.injectionText || "").trim() === injectionText && areRecallNodeIdListsEqual(existingRecord.selectedNodeIds, selectedNodeIds) && - String(existingRecord.recallInput || "").trim() + String(existingRecord.recallInput || "").trim() && + existingMetadataUpToDate ) { return { persisted: false, @@ -5421,17 +5452,8 @@ function ensurePersistedRecallRecordForGeneration({ ), tokenEstimate: estimateTokens(injectionText), manuallyEdited: false, - authoritativeInputUsed: Boolean( - recallResult?.authoritativeInputUsed ?? - frozenRecallOptions?.authoritativeInputUsed ?? - recallOptions?.authoritativeInputUsed, - ), - boundUserFloorText: String( - recallResult?.boundUserFloorText || - frozenRecallOptions?.boundUserFloorText || - recallOptions?.boundUserFloorText || - "", - ), + authoritativeInputUsed: nextAuthoritativeInputUsed, + boundUserFloorText: nextBoundUserFloorText, }, existingRecord, ); @@ -20042,10 +20064,14 @@ function createGenerationRecallContext({ transaction.chatId, ); const transactionRecallKey = String(transaction.recallKey || "").trim(); + const peerHookName = getGenerationRecallPeerHookName(hookName); + const hasPeerHookState = Boolean( + peerHookName && transaction.hookStates?.[peerHookName], + ); if ( normalizedTransactionChatId !== normalizedChatId || !transactionRecallKey || - transactionRecallKey !== String(fallbackRecallKey) + (!hasPeerHookState && transactionRecallKey !== String(fallbackRecallKey)) ) { return { hookName, diff --git a/retrieval/recall-controller.js b/retrieval/recall-controller.js index 8e1f5bd..3e30582 100644 --- a/retrieval/recall-controller.js +++ b/retrieval/recall-controller.js @@ -132,8 +132,8 @@ function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) { ); const matchesCurrentUserFloor = Boolean( currentUserFloorText && - recordRecallInput && - currentUserFloorText === recordRecallInput, + boundUserFloorText && + currentUserFloorText === boundUserFloorText, ); if (record.authoritativeInputUsed) { diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index 4859afc..d74e6c7 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -269,7 +269,7 @@ export function createGenerationRecallHarness(options = {}) { }; vm.createContext(context); vm.runInContext( - `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, preparePlannerRecallHandoff, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`, + `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, persistRecallInjectionRecord, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, preparePlannerRecallHandoff, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`, context, { filename: indexPath }, ); diff --git a/tests/recall-reroll-reuse.mjs b/tests/recall-reroll-reuse.mjs index aa78095..976cfb8 100644 --- a/tests/recall-reroll-reuse.mjs +++ b/tests/recall-reroll-reuse.mjs @@ -38,6 +38,40 @@ Object.assign(harness.settings, { recallEnabled: true, }); +harness.chat = [ + { is_user: true, mes: "楼层里的稳定用户输入" }, + { is_user: false, mes: "好的。", is_system: false }, +]; + +const persistedWriteResult = harness.result.persistRecallInjectionRecord({ + recallInput: { + userMessage: "发送前捕获的权威输入", + source: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", + targetUserMessageIndex: 0, + authoritativeInputUsed: true, + boundUserFloorText: "楼层里的稳定用户输入", + }, + result: { + selectedNodeIds: ["node-write-1"], + }, + injectionText: "注入:楼层里的稳定用户输入", + tokenEstimate: 8, +}); +assert.ok(persistedWriteResult?.record, "persistRecallInjectionRecord should write a record"); +assert.equal( + persistedWriteResult.record.authoritativeInputUsed, + true, + "initial persisted record should keep authoritativeInputUsed", +); +assert.equal( + persistedWriteResult.record.boundUserFloorText, + "楼层里的稳定用户输入", + "initial persisted record should keep boundUserFloorText", +); + +console.log(" ✓ persistRecallInjectionRecord stores authoritative input metadata"); + // Set up chat: user + assistant harness.chat = [ { is_user: true, mes: "去摩耶山看夜景" }, @@ -118,10 +152,77 @@ assert.equal( console.log(" ✓ ensurePersistedRecallRecordForGeneration overwrites record with empty recallInput"); +harness.chat = [ + { is_user: true, mes: "稳定楼层文本" }, + { is_user: false, mes: "好的。", is_system: false }, +]; +const staleMetadataRecord = buildPersistedRecallRecord({ + injectionText: "注入:稳定楼层文本", + selectedNodeIds: ["node-stale-meta"], + recallInput: "发送前捕获文本", + recallSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", + tokenEstimate: 4, + manuallyEdited: false, +}); +writePersistedRecallToUserMessage(harness.chat, 0, staleMetadataRecord); +const staleMetadataEnsureResult = harness.result.ensurePersistedRecallRecordForGeneration({ + generationType: "regenerate", + recallResult: { + status: "completed", + didRecall: true, + ok: true, + injectionText: "注入:稳定楼层文本", + selectedNodeIds: ["node-stale-meta"], + recallInput: "发送前捕获文本", + source: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", + authoritativeInputUsed: false, + boundUserFloorText: "稳定楼层文本", + }, + transaction: { + frozenRecallOptions: { + generationType: "regenerate", + targetUserMessageIndex: 0, + overrideUserMessage: "稳定楼层文本", + overrideSource: "chat-last-user", + authoritativeInputUsed: false, + boundUserFloorText: "稳定楼层文本", + }, + }, + recallOptions: { + generationType: "regenerate", + targetUserMessageIndex: 0, + overrideUserMessage: "稳定楼层文本", + authoritativeInputUsed: false, + boundUserFloorText: "稳定楼层文本", + }, + hookName: "GENERATION_AFTER_COMMANDS", +}); +assert.equal( + staleMetadataEnsureResult.persisted, + true, + "ensure should rewrite records whose metadata is stale even when text/nodeIds match", +); +const repairedMetadataRecord = readPersistedRecallFromUserMessage(harness.chat, 0); +assert.equal( + repairedMetadataRecord.boundUserFloorText, + "稳定楼层文本", + "ensure should repair missing boundUserFloorText", +); + +console.log(" ✓ ensurePersistedRecallRecordForGeneration repairs stale metadata"); + // ═══════════════════════════════════════════════════════════════ // 2. ensurePersistedRecallRecordForGeneration: populated recallInput skip // ═══════════════════════════════════════════════════════════════ +harness.chat = [ + { is_user: true, mes: "去摩耶山看夜景" }, + { is_user: false, mes: "好的,我们出发吧。", is_system: false }, +]; +writePersistedRecallToUserMessage(harness.chat, 0, afterRecord); + // Now the record has proper recallInput — calling ensure again should skip const ensureResult2 = harness.result.ensurePersistedRecallRecordForGeneration({ generationType: "regenerate", @@ -138,6 +239,56 @@ assert.equal( console.log(" ✓ ensurePersistedRecallRecordForGeneration skips when recallInput is populated"); +harness.chat = [ + { is_user: true, mes: "继续写摩耶山夜景" }, + { is_user: false, mes: "前一次回复。", is_system: false }, +]; +harness.result.cleanupGenerationRecallTransactions(Date.now() + 60000); +const afterCommandsRecallOptions = harness.result.buildGenerationAfterCommandsRecallInput( + "regenerate", + {}, + harness.chat, +); +const afterCommandsContext = harness.result.createGenerationRecallContext({ + hookName: "GENERATION_AFTER_COMMANDS", + generationType: "regenerate", + recallOptions: afterCommandsRecallOptions, +}); +assert.ok( + afterCommandsContext.transaction, + "after-commands should create a history transaction", +); +harness.result.markGenerationRecallTransactionHookState( + afterCommandsContext.transaction, + "GENERATION_AFTER_COMMANDS", + "completed", +); +const beforeCombineNormalFallback = harness.result.buildNormalGenerationRecallInput( + harness.chat, +); +const beforeCombineContext = harness.result.createGenerationRecallContext({ + hookName: "GENERATE_BEFORE_COMBINE_PROMPTS", + generationType: "normal", + recallOptions: beforeCombineNormalFallback, +}); +assert.equal( + beforeCombineContext.transaction, + afterCommandsContext.transaction, + "before-combine should reuse the existing history transaction despite normal fallback input", +); +assert.equal( + beforeCombineContext.generationType, + "regenerate", + "before-combine should keep the transaction's history generation type", +); +assert.equal( + beforeCombineContext.shouldRun, + false, + "before-combine should not run recall again after after-commands completed", +); + +console.log(" ✓ before-combine reuses existing history transaction"); + // ═══════════════════════════════════════════════════════════════ // 3. runRecallController: regenerate reuses persisted record // ═══════════════════════════════════════════════════════════════ @@ -151,8 +302,8 @@ const rerollChat = [ const validRecord = buildPersistedRecallRecord({ injectionText: "注入:明日去摩耶山看夜景", selectedNodeIds: ["node-a"], - recallInput: "明日去摩耶山看夜景", - recallSource: "chat-tail-user", + recallInput: "发送意图中的扩展文本,不等于当前用户楼层", + recallSource: "send-intent", hookName: "GENERATION_AFTER_COMMANDS", tokenEstimate: 5, manuallyEdited: false,