From d5b4b7e1dc286b731b10930f11560693ca151def Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Tue, 31 Mar 2026 16:33:40 +0800 Subject: [PATCH] fix: freeze recall input from host lifecycle --- event-binding.js | 47 +++++++++++++++++----- index.js | 84 +++++++++++++++++++++++++++++++++++++--- tests/p0-regressions.mjs | 38 +++++++++++++++++- 3 files changed, 154 insertions(+), 15 deletions(-) diff --git a/event-binding.js b/event-binding.js index 53f845a..44cedff 100644 --- a/event-binding.js +++ b/event-binding.js @@ -8,7 +8,10 @@ export function registerBeforeCombinePromptsController(runtime, listener) { } runtime.console.warn("[ST-BME] eventMakeFirst 不可用,回退到普通事件注册"); - runtime.eventSource.on(runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS, listener); + runtime.eventSource.on( + runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS, + listener, + ); return null; } @@ -21,7 +24,10 @@ export function registerGenerationAfterCommandsController(runtime, listener) { runtime.console.warn( "[ST-BME] eventMakeFirst 不可用,GENERATION_AFTER_COMMANDS 回退到普通事件注册", ); - runtime.eventSource.on(runtime.eventTypes.GENERATION_AFTER_COMMANDS, listener); + runtime.eventSource.on( + runtime.eventTypes.GENERATION_AFTER_COMMANDS, + listener, + ); return null; } @@ -48,7 +54,10 @@ export function installSendIntentHooksController(runtime) { if (sendButton) { const captureSendIntent = () => { - runtime.recordRecallSendIntent(runtime.getSendTextareaValue(), "send-button"); + runtime.recordRecallSendIntent( + runtime.getSendTextareaValue(), + "send-button", + ); }; sendButton.addEventListener("click", captureSendIntent, true); @@ -182,25 +191,36 @@ export async function onGenerationAfterCommandsController( ) { if (dryRun) return; + const generationType = String(type || "normal").trim() || "normal"; + const frozenInputSnapshot = + generationType === "normal" + ? runtime.consumeHostGenerationInputSnapshot?.({ preserve: true }) || + runtime.consumeHostGenerationInputSnapshot?.() + : null; + const context = runtime.getContext(); const chat = context?.chat; const recallOptions = runtime.buildGenerationAfterCommandsRecallInput( type, - params, + { + ...params, + frozenInputSnapshot, + }, chat, ); if (!recallOptions) return; const recallContext = runtime.createGenerationRecallContext({ hookName: "GENERATION_AFTER_COMMANDS", - generationType: String(type || "normal").trim() || "normal", + generationType, recallOptions, }); if (!recallContext.shouldRun) { return; } - const runtimeRecallOptions = recallContext.recallOptions || recallOptions || {}; + const runtimeRecallOptions = + recallContext.recallOptions || recallOptions || {}; runtime.markGenerationRecallTransactionHookState( recallContext.transaction, recallContext.hookName, @@ -226,10 +246,17 @@ export async function onGenerationAfterCommandsController( } export async function onBeforeCombinePromptsController(runtime) { + const frozenInputSnapshot = + runtime.consumeHostGenerationInputSnapshot?.() || + runtime.getPendingHostGenerationInputSnapshot?.() || + runtime.createRecallInputRecord?.() || + {}; const context = runtime.getContext(); const chat = context?.chat; const recallOptions = - runtime.buildNormalGenerationRecallInput(chat) || + runtime.buildNormalGenerationRecallInput(chat, { + frozenInputSnapshot, + }) || runtime.buildHistoryGenerationRecallInput(chat) || {}; const recallContext = runtime.createGenerationRecallContext({ @@ -241,7 +268,8 @@ export async function onBeforeCombinePromptsController(runtime) { return; } - const runtimeRecallOptions = recallContext.recallOptions || recallOptions || {}; + const runtimeRecallOptions = + recallContext.recallOptions || recallOptions || {}; runtime.markGenerationRecallTransactionHookState( recallContext.transaction, recallContext.hookName, @@ -268,7 +296,8 @@ export function onMessageReceivedController(runtime) { const persistenceState = runtime.getGraphPersistenceState?.() || {}; const loadState = persistenceState.loadState || ""; const dbReady = - persistenceState.dbReady ?? (loadState === "loaded" || loadState === "empty-confirmed"); + persistenceState.dbReady ?? + (loadState === "loaded" || loadState === "empty-confirmed"); if ( !dbReady || loadState === "loading" || diff --git a/index.js b/index.js index f91fd9d..deb1d08 100644 --- a/index.js +++ b/index.js @@ -446,6 +446,7 @@ let graphPersistenceState = createGraphPersistenceState(); const lastStatusToastAt = {}; let pendingRecallSendIntent = createRecallInputRecord(); let lastRecallSentUserMessage = createRecallInputRecord(); +let pendingHostGenerationInputSnapshot = createRecallInputRecord(); let sendIntentHookCleanup = []; let sendIntentHookRetryTimer = null; let pendingHistoryRecoveryTimer = null; @@ -1027,6 +1028,45 @@ function updateLastRecalledItems(nodeIds = []) { function clearRecallInputTracking() { pendingRecallSendIntent = createRecallInputRecord(); lastRecallSentUserMessage = createRecallInputRecord(); + pendingHostGenerationInputSnapshot = createRecallInputRecord(); +} + +function freezeHostGenerationInputSnapshot( + text, + source = "host-generation-lifecycle", +) { + const normalized = normalizeRecallInputText(text); + if (!normalized) return null; + + pendingHostGenerationInputSnapshot = createRecallInputRecord({ + text: normalized, + hash: hashRecallInput(normalized), + source, + at: Date.now(), + }); + return pendingHostGenerationInputSnapshot; +} + +function consumeHostGenerationInputSnapshot(options = {}) { + const { preserve = false } = options; + if (!isFreshRecallInputRecord(pendingHostGenerationInputSnapshot)) { + if (!preserve) { + pendingHostGenerationInputSnapshot = createRecallInputRecord(); + } + return createRecallInputRecord(); + } + + const snapshot = createRecallInputRecord({ + ...pendingHostGenerationInputSnapshot, + }); + if (!preserve) { + pendingHostGenerationInputSnapshot = createRecallInputRecord(); + } + return snapshot; +} + +function getPendingHostGenerationInputSnapshot() { + return pendingHostGenerationInputSnapshot; } function recordRecallSendIntent(text, source = "dom-intent") { @@ -1057,6 +1097,12 @@ function recordRecallSentUserMessage(messageId, text, source = "message-sent") { if (pendingRecallSendIntent.hash && pendingRecallSendIntent.hash === hash) { pendingRecallSendIntent = createRecallInputRecord(); } + if ( + pendingHostGenerationInputSnapshot.hash && + pendingHostGenerationInputSnapshot.hash === hash + ) { + pendingHostGenerationInputSnapshot = createRecallInputRecord(); + } } function getMessageRecallRecord(messageIndex) { @@ -4830,10 +4876,12 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { }; } - return buildNormalGenerationRecallInput(chat); + return buildNormalGenerationRecallInput(chat, { + frozenInputSnapshot: params?.frozenInputSnapshot, + }); } -function buildNormalGenerationRecallInput(chat) { +function buildNormalGenerationRecallInput(chat, options = {}) { const lastNonSystemMessage = getLastNonSystemChatMessage(chat); const tailUserText = lastNonSystemMessage?.is_user ? normalizeRecallInputText(lastNonSystemMessage?.mes || "") @@ -4841,18 +4889,38 @@ function buildNormalGenerationRecallInput(chat) { const targetUserMessageIndex = resolveGenerationTargetUserMessageIndex(chat, { generationType: "normal", }); + const frozenInputSnapshot = isFreshRecallInputRecord( + options?.frozenInputSnapshot, + ) + ? options.frozenInputSnapshot + : null; + const hostSnapshotText = normalizeRecallInputText( + frozenInputSnapshot?.text || "", + ); const textareaText = normalizeRecallInputText( pendingRecallSendIntent.text || getSendTextareaValue(), ); - const userMessage = tailUserText || textareaText; + const userMessage = tailUserText || hostSnapshotText || textareaText; if (!userMessage) return null; + let overrideSource = "send-intent"; + let overrideSourceLabel = "发送意图"; + if (tailUserText) { + overrideSource = "chat-tail-user"; + overrideSourceLabel = "当前用户楼层"; + } else if (hostSnapshotText) { + overrideSource = String( + frozenInputSnapshot?.source || "host-generation-lifecycle", + ); + overrideSourceLabel = "宿主发送快照"; + } + return { overrideUserMessage: userMessage, generationType: "normal", targetUserMessageIndex, - overrideSource: tailUserText ? "chat-tail-user" : "send-intent", - overrideSourceLabel: tailUserText ? "当前用户楼层" : "发送意图", + overrideSource, + overrideSourceLabel, includeSyntheticUserMessage: !tailUserText, }; } @@ -6514,6 +6582,7 @@ async function onGenerationAfterCommands(type, params = {}, dryRun = false) { { applyFinalRecallInjectionForGeneration, buildGenerationAfterCommandsRecallInput, + consumeHostGenerationInputSnapshot, createGenerationRecallContext, getContext, getGenerationRecallHookStateFromResult, @@ -6531,6 +6600,7 @@ async function onBeforeCombinePrompts() { applyFinalRecallInjectionForGeneration, buildHistoryGenerationRecallInput, buildNormalGenerationRecallInput, + consumeHostGenerationInputSnapshot, createGenerationRecallContext, getContext, getGenerationRecallHookStateFromResult, @@ -6546,6 +6616,7 @@ function onMessageReceived() { getContext, getCurrentGraph: () => currentGraph, getGraphPersistenceState: () => graphPersistenceState, + getPendingHostGenerationInputSnapshot, getPendingRecallSendIntent: () => pendingRecallSendIntent, isAssistantChatMessage, isFreshRecallInputRecord, @@ -6557,6 +6628,9 @@ function onMessageReceived() { queueMicrotask, runExtraction, refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, + setPendingHostGenerationInputSnapshot: (record) => { + pendingHostGenerationInputSnapshot = record; + }, setPendingRecallSendIntent: (record) => { pendingRecallSendIntent = record; }, diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 19fbbfb..ceed6d0 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -270,6 +270,7 @@ function createGenerationRecallHarness() { 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, @@ -341,7 +342,7 @@ function createGenerationRecallHarness() { }; vm.createContext(context); vm.runInContext( - `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationAfterCommands, onBeforeCombinePrompts, generationRecallTransactions };`, + `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationAfterCommands, onBeforeCombinePrompts, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot };`, context, { filename: indexPath }, ); @@ -2264,6 +2265,40 @@ async function testGenerationRecallBeforeCombineCanUseProvisionalSendIntentBindi 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" }]; @@ -3016,6 +3051,7 @@ await testGenerationRecallHistoryModesUseSameBindingAcrossHooks(); await testGenerationRecallFrozenBindingSurvivesCrossHookInputDrift(); await testGenerationRecallSkipsUntilTargetUserFloorAvailable(); await testGenerationRecallBeforeCombineCanUseProvisionalSendIntentBinding(); +await testGenerationRecallHostLifecycleSnapshotSurvivesTextareaClearWithoutDomIntent(); await testGenerationRecallAfterCommandsStillSkipsWithoutStableUserFloor(); await testGenerationRecallSameKeyCanRunAgainImmediatelyAsNewGeneration(); await testGenerationRecallSameKeyCanRunAgainAfterBridgeWindow();