fix(recall): preserve reroll user-floor reuse

This commit is contained in:
opencode
2026-05-15 20:13:13 +00:00
parent 94f42ddb23
commit d2d01d2c24
4 changed files with 211 additions and 2 deletions

View File

@@ -467,9 +467,15 @@ export function onMessageUpdatedController(runtime, messageId, meta = null) {
} }
export async function onMessageSwipedController(runtime, messageId, meta = null) { export async function onMessageSwipedController(runtime, messageId, meta = null) {
runtime.invalidateRecallAfterHistoryMutation("已切换楼层 swipe");
const parsedFloor = Number(messageId); const parsedFloor = Number(messageId);
const fromFloor = Number.isFinite(parsedFloor) ? parsedFloor : undefined; const fromFloor = Number.isFinite(parsedFloor) ? parsedFloor : undefined;
const preparedRerollReuse = runtime.prepareRerollRecallReuse?.({
fromFloor,
meta,
});
if (!preparedRerollReuse) {
runtime.invalidateRecallAfterHistoryMutation("已切换楼层 swipe");
}
let result = { let result = {
success: false, success: false,
rollbackPerformed: false, rollbackPerformed: false,
@@ -503,6 +509,9 @@ export async function onMessageSwipedController(runtime, messageId, meta = null)
{ messageId, meta }, { messageId, meta },
); );
} }
if (!result?.success) {
runtime.clearPendingRerollRecallReuse?.("swipe-reroll-failed");
}
runtime.refreshPersistedRecallMessageUi?.(); runtime.refreshPersistedRecallMessageUi?.();
return result; return result;
} }

139
index.js
View File

@@ -1356,6 +1356,7 @@ const dismissedStageNoticeSignatures = new Map();
let pendingRecallSendIntent = createRecallInputRecord(); let pendingRecallSendIntent = createRecallInputRecord();
let lastRecallSentUserMessage = createRecallInputRecord(); let lastRecallSentUserMessage = createRecallInputRecord();
let pendingHostGenerationInputSnapshot = createRecallInputRecord(); let pendingHostGenerationInputSnapshot = createRecallInputRecord();
let pendingRerollRecallReuse = null;
let currentGenerationTrivialSkip = null; let currentGenerationTrivialSkip = null;
let coreEventBindingState = { let coreEventBindingState = {
registered: false, registered: false,
@@ -5002,6 +5003,7 @@ function clearRecallInputTracking() {
clearPendingRecallSendIntent(); clearPendingRecallSendIntent();
lastRecallSentUserMessage = createRecallInputRecord(); lastRecallSentUserMessage = createRecallInputRecord();
clearPendingHostGenerationInputSnapshot(); clearPendingHostGenerationInputSnapshot();
clearPendingRerollRecallReuse("recall-input-tracking-cleared");
if (typeof recordMessageTraceSnapshot === "function") { if (typeof recordMessageTraceSnapshot === "function") {
recordMessageTraceSnapshot({ recordMessageTraceSnapshot({
lastSentUserMessage: null, lastSentUserMessage: null,
@@ -19466,6 +19468,18 @@ function getLatestUserChatMessage(chat) {
return null; return null;
} }
function findLatestUserChatMessageWithIndex(chat) {
if (!Array.isArray(chat)) return null;
for (let index = chat.length - 1; index >= 0; index--) {
const message = chat[index];
if (isSystemMessageForExtraction(message, { index, chat })) continue;
if (message?.is_user) return { message, index };
}
return null;
}
function getLastNonSystemChatMessage(chat) { function getLastNonSystemChatMessage(chat) {
if (!Array.isArray(chat)) return null; if (!Array.isArray(chat)) return null;
@@ -19479,6 +19493,124 @@ function getLastNonSystemChatMessage(chat) {
return null; return null;
} }
function getPendingRerollRecallReuse() {
return pendingRerollRecallReuse;
}
function clearPendingRerollRecallReuse(reason = "") {
const previous = pendingRerollRecallReuse;
pendingRerollRecallReuse = null;
return previous;
}
function prepareRerollRecallReuse({ fromFloor = null, meta = null } = {}) {
const context = getContext();
const chat = context?.chat;
if (!Array.isArray(chat) || chat.length === 0) {
pendingRerollRecallReuse = null;
return null;
}
const latestUser = findLatestUserChatMessageWithIndex(chat);
const targetUserMessageIndex = Number.isFinite(latestUser?.index)
? latestUser.index
: null;
if (!Number.isFinite(targetUserMessageIndex)) {
pendingRerollRecallReuse = null;
return null;
}
const userMessage = chat[targetUserMessageIndex];
const userText = normalizeRecallInputText(userMessage?.mes || "");
if (!userText) {
pendingRerollRecallReuse = null;
return null;
}
const persistedRecord = readPersistedRecallFromUserMessage(
chat,
targetUserMessageIndex,
);
const persistedInjection = normalizeRecallInputText(
persistedRecord?.injectionText || "",
);
if (!persistedRecord || !persistedInjection) {
pendingRerollRecallReuse = null;
return null;
}
const boundText = normalizeRecallInputText(
persistedRecord?.boundUserFloorText || persistedRecord?.recallInput || "",
);
if (boundText && boundText !== userText) {
pendingRerollRecallReuse = null;
return null;
}
const chatId = normalizeChatIdCandidate(getCurrentChatId(context));
pendingRerollRecallReuse = {
chatId,
fromFloor: Number.isFinite(Number(fromFloor)) ? Math.floor(Number(fromFloor)) : null,
targetUserMessageIndex,
userText,
userHash: hashRecallInput(userText),
createdAt: Date.now(),
meta,
};
return pendingRerollRecallReuse;
}
function consumePendingRerollRecallReuse(chat = getContext()?.chat) {
const reuse = pendingRerollRecallReuse;
if (!reuse) return null;
const activeChatId = normalizeChatIdCandidate(getCurrentChatId());
if (reuse.chatId && activeChatId && reuse.chatId !== activeChatId) {
pendingRerollRecallReuse = null;
return null;
}
if (Date.now() - Number(reuse.createdAt || 0) > GENERATION_RECALL_TRANSACTION_TTL_MS) {
pendingRerollRecallReuse = null;
return null;
}
const latestUser = findLatestUserChatMessageWithIndex(chat);
const targetUserMessageIndex = Number.isFinite(latestUser?.index)
? latestUser.index
: reuse.targetUserMessageIndex;
if (targetUserMessageIndex !== reuse.targetUserMessageIndex) {
pendingRerollRecallReuse = null;
return null;
}
const userText = normalizeRecallInputText(chat?.[targetUserMessageIndex]?.mes || "");
if (!userText || hashRecallInput(userText) !== reuse.userHash) {
pendingRerollRecallReuse = null;
return null;
}
pendingRerollRecallReuse = null;
return {
overrideUserMessage: userText,
generationType: "normal",
targetUserMessageIndex,
overrideSource: "chat-last-user",
overrideSourceLabel: "历史最后用户楼层",
overrideReason: "reroll-user-floor-reuse",
sourceCandidates: [
{
text: userText,
source: "chat-last-user",
sourceLabel: "历史最后用户楼层",
reason: "reroll-user-floor-reuse",
includeSyntheticUserMessage: false,
},
],
includeSyntheticUserMessage: false,
rerollRecallReuse: true,
};
}
function buildRecallRecentMessages(chat, limit, syntheticUserMessage = "") { function buildRecallRecentMessages(chat, limit, syntheticUserMessage = "") {
return buildRecallRecentMessagesController( return buildRecallRecentMessagesController(
chat, chat,
@@ -19574,6 +19706,11 @@ function createTrivialRecallSkipSentinel(reason = "") {
} }
function buildNormalGenerationRecallInput(chat, options = {}) { function buildNormalGenerationRecallInput(chat, options = {}) {
const rerollReuse = consumePendingRerollRecallReuse(chat);
if (rerollReuse) {
return rerollReuse;
}
const lastNonSystemMessage = getLastNonSystemChatMessage(chat); const lastNonSystemMessage = getLastNonSystemChatMessage(chat);
const tailUserText = lastNonSystemMessage?.is_user const tailUserText = lastNonSystemMessage?.is_user
? normalizeRecallInputText(lastNonSystemMessage?.mes || "") ? normalizeRecallInputText(lastNonSystemMessage?.mes || "")
@@ -22869,6 +23006,8 @@ async function onMessageSwiped(messageId, meta = null) {
{ {
invalidateRecallAfterHistoryMutation, invalidateRecallAfterHistoryMutation,
onReroll, onReroll,
prepareRerollRecallReuse,
clearPendingRerollRecallReuse,
refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh,
scheduleHistoryMutationRecheck, scheduleHistoryMutationRecheck,
}, },

View File

@@ -269,7 +269,7 @@ export function createGenerationRecallHarness(options = {}) {
}; };
vm.createContext(context); vm.createContext(context);
vm.runInContext( vm.runInContext(
`${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; } };`, `${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, prepareRerollRecallReuse, getPendingRerollRecallReuse, clearPendingRerollRecallReuse, 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, context,
{ filename: indexPath }, { filename: indexPath },
); );

View File

@@ -508,6 +508,67 @@ assert.equal(
console.log(" ✓ runRecallController reuses persisted record when host reports reroll as normal"); console.log(" ✓ runRecallController reuses persisted record when host reports reroll as normal");
const rerollInputHarness = await createGenerationRecallHarness({ realApplyFinal: true });
Object.assign(rerollInputHarness.settings, {
...defaultSettings,
enabled: true,
recallEnabled: true,
recallUseAuthoritativeGenerationInput: true,
});
rerollInputHarness.chat = [
{ is_user: true, mes: "重 roll replacement 应复用这一楼" },
{ is_user: false, mes: "旧 assistant 回复", is_system: false },
];
writePersistedRecallToUserMessage(
rerollInputHarness.chat,
0,
buildPersistedRecallRecord({
injectionText: "注入:重 roll replacement 应复用这一楼",
selectedNodeIds: ["node-reroll-input"],
recallInput: "重 roll replacement 应复用这一楼",
recallSource: "chat-last-user",
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 6,
manuallyEdited: false,
boundUserFloorText: "重 roll replacement 应复用这一楼",
}),
);
const preparedRerollReuse = rerollInputHarness.result.prepareRerollRecallReuse({
fromFloor: 1,
});
assert.ok(preparedRerollReuse, "assistant-only reroll should prepare recall reuse marker");
rerollInputHarness.result.recordRecallSendIntent(
"错误的主动输入不应覆盖 reroll 用户楼",
"send-intent",
);
rerollInputHarness.result.freezeHostGenerationInputSnapshot(
"错误的宿主快照不应覆盖 reroll 用户楼",
"host-generation-lifecycle",
);
const rerollReplacementInput = rerollInputHarness.result.buildNormalGenerationRecallInput(
rerollInputHarness.chat,
{
frozenInputSnapshot: rerollInputHarness.result.getPendingHostGenerationInputSnapshot(),
},
);
assert.equal(
rerollReplacementInput.overrideUserMessage,
"重 roll replacement 应复用这一楼",
"reroll replacement should ignore stale live input sources and bind to stable user floor",
);
assert.equal(rerollReplacementInput.overrideSource, "chat-last-user");
assert.equal(rerollReplacementInput.overrideReason, "reroll-user-floor-reuse");
assert.equal(
rerollInputHarness.result.getPendingRerollRecallReuse(),
null,
"reroll reuse marker should be one-shot after binding recall input",
);
console.log(" ✓ reroll replacement normal input is forced to stable user-floor recall source");
const legacyUnboundReuseChat = [ const legacyUnboundReuseChat = [
{ is_user: true, mes: "旧记录没有绑定楼层" }, { is_user: true, mes: "旧记录没有绑定楼层" },
{ is_user: false, mes: "上一条回复。", is_system: false }, { is_user: false, mes: "上一条回复。", is_system: false },