mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Harden graph recovery and shadow persistence
This commit is contained in:
@@ -87,6 +87,34 @@ assert.deepEqual(
|
||||
"extraction should keep BME-managed hidden context but still skip real system messages",
|
||||
);
|
||||
|
||||
const blankAssistantChat = [
|
||||
{ is_user: false, is_system: true, mes: "greeting/system" },
|
||||
{ is_user: true, is_system: false, mes: "user-1" },
|
||||
{ is_user: false, is_system: false, mes: " " },
|
||||
{ is_user: true, is_system: false, mes: "<plot>secret</plot>" },
|
||||
{ is_user: false, is_system: false, mes: "assistant-2" },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
getAssistantTurns(blankAssistantChat),
|
||||
[4],
|
||||
"blank assistant floors should not be treated as extractable turns",
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildExtractionMessages(blankAssistantChat, 4, 4, {
|
||||
extractContextTurns: 3,
|
||||
}).map((message) => ({
|
||||
seq: message.seq,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
})),
|
||||
[
|
||||
{ seq: 1, role: "user", content: "user-1" },
|
||||
{ seq: 4, role: "assistant", content: "assistant-2" },
|
||||
],
|
||||
"blank assistant text and planner-tag-only user text should be skipped",
|
||||
);
|
||||
|
||||
resetHideState();
|
||||
const autoHiddenChat = [
|
||||
{ is_user: false, is_system: true, mes: "greeting/system" },
|
||||
|
||||
@@ -1641,13 +1641,16 @@ result = {
|
||||
source: "shadow-test",
|
||||
});
|
||||
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(reader.api.getCurrentGraph(), null);
|
||||
assert.equal(result.loadState, "shadow-restored");
|
||||
assert.equal(
|
||||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||
"事件-shadow",
|
||||
);
|
||||
assert.equal(
|
||||
reader.api.getGraphPersistenceLiveState().shadowSnapshotUsed,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, true);
|
||||
assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, false);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1949,7 +1952,7 @@ result = {
|
||||
});
|
||||
const live = reader.api.getGraphPersistenceLiveState();
|
||||
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(result.loadState, "shadow-restored");
|
||||
assert.equal(
|
||||
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes
|
||||
?.length,
|
||||
@@ -1961,8 +1964,8 @@ result = {
|
||||
);
|
||||
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
|
||||
assert.equal(reader.runtimeContext.__contextSaveCalls, 0);
|
||||
assert.equal(live.lastPersistedRevision, 0);
|
||||
assert.equal(live.pendingPersist, false);
|
||||
assert.equal(live.lastPersistedRevision, 9);
|
||||
assert.equal(live.pendingPersist, true);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -2102,7 +2105,7 @@ result = {
|
||||
source: "load-shadow-decoupled",
|
||||
});
|
||||
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(result.loadState, "shadow-restored");
|
||||
const runtimeGraph = reader.api.getCurrentGraph();
|
||||
const persistedGraph =
|
||||
reader.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||||
@@ -2113,6 +2116,10 @@ result = {
|
||||
);
|
||||
|
||||
runtimeGraph.nodes[0].fields.title = "runtime-shadow-mutated";
|
||||
assert.equal(
|
||||
runtimeGraph.nodes[0].fields.title,
|
||||
"runtime-shadow-mutated",
|
||||
);
|
||||
assert.equal(
|
||||
persistedGraph.nodes[0].fields.title,
|
||||
"事件-official-older",
|
||||
@@ -2355,6 +2362,65 @@ result = {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const sharedSession = new Map();
|
||||
const writer = await createGraphPersistenceHarness({
|
||||
chatId: "chat-indexeddb-shadow-restore",
|
||||
globalChatId: "chat-indexeddb-shadow-restore",
|
||||
sessionStore: sharedSession,
|
||||
});
|
||||
writer.api.writeGraphShadowSnapshot(
|
||||
"chat-indexeddb-shadow-restore",
|
||||
createMeaningfulGraph("chat-indexeddb-shadow-restore", "shadow-newer"),
|
||||
{
|
||||
revision: 9,
|
||||
reason: "pagehide-refresh",
|
||||
},
|
||||
);
|
||||
|
||||
const indexedDbGraph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-indexeddb-shadow-restore", "indexeddb-older"),
|
||||
{
|
||||
revision: 4,
|
||||
integrity: "meta-indexeddb-shadow-restore",
|
||||
chatId: "chat-indexeddb-shadow-restore",
|
||||
reason: "indexeddb-older",
|
||||
},
|
||||
);
|
||||
const indexedDbSnapshot = buildSnapshotFromGraph(indexedDbGraph, {
|
||||
chatId: "chat-indexeddb-shadow-restore",
|
||||
revision: 4,
|
||||
});
|
||||
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-indexeddb-shadow-restore",
|
||||
globalChatId: "chat-indexeddb-shadow-restore",
|
||||
indexedDbSnapshot,
|
||||
sessionStore: sharedSession,
|
||||
});
|
||||
|
||||
const result = await harness.api.loadGraphFromIndexedDb(
|
||||
"chat-indexeddb-shadow-restore",
|
||||
{
|
||||
source: "indexeddb-shadow-restore",
|
||||
allowOverride: true,
|
||||
applyEmptyState: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.loadState, "shadow-restored");
|
||||
assert.equal(
|
||||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||
"事件-shadow-newer",
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
harness.api.getIndexedDbSnapshot().meta.revision,
|
||||
9,
|
||||
"shadow 恢复后应回补 IndexedDB 修正旧快照",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const legacyGraph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-legacy-migration", "legacy"),
|
||||
|
||||
@@ -140,6 +140,10 @@ export function createGenerationRecallHarness(options = {}) {
|
||||
recordedInjectionSnapshots: [],
|
||||
refreshPanelCalls: 0,
|
||||
hideScheduleCalls: [],
|
||||
isExtracting: false,
|
||||
isRecoveringHistory: false,
|
||||
isAssistantChatMessage: (message) =>
|
||||
Boolean(message) && !message.is_user && !message.is_system,
|
||||
createRecallInputRecord,
|
||||
createRecallRunResult,
|
||||
hashRecallInput,
|
||||
@@ -215,7 +219,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, 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, 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, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`,
|
||||
context,
|
||||
{ filename: indexPath },
|
||||
);
|
||||
@@ -322,9 +326,12 @@ export function createGenerationRecallHarness(options = {}) {
|
||||
consumeCurrentGenerationTrivialSkip:
|
||||
context.result.consumeCurrentGenerationTrivialSkip,
|
||||
createRecallInputRecord,
|
||||
deferAutoExtraction: context.result.deferAutoExtraction,
|
||||
getContext: context.getContext,
|
||||
getCurrentGraph: () => context.currentGraph,
|
||||
getGraphPersistenceState: () => context.result.getGraphPersistenceState(),
|
||||
getIsHostGenerationRunning: () =>
|
||||
context.result.getIsHostGenerationRunning(),
|
||||
getPendingHostGenerationInputSnapshot:
|
||||
context.result.getPendingHostGenerationInputSnapshot,
|
||||
getPendingRecallSendIntent: () => context.result.getPendingRecallSendIntent(),
|
||||
|
||||
@@ -3716,6 +3716,89 @@ async function testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask()
|
||||
assert.equal(refreshCalls, 1);
|
||||
}
|
||||
|
||||
async function testMessageReceivedDefersExtractionDuringHostGeneration() {
|
||||
let runExtractionCalls = 0;
|
||||
const deferred = [];
|
||||
|
||||
onMessageReceivedController(
|
||||
{
|
||||
getGraphPersistenceState: () => ({ loadState: "loaded", dbReady: true }),
|
||||
getCurrentGraph: () => null,
|
||||
getPendingRecallSendIntent: () => ({ text: "", at: 0 }),
|
||||
getIsHostGenerationRunning: () => true,
|
||||
isFreshRecallInputRecord: () => true,
|
||||
createRecallInputRecord: () => ({ text: "", at: 0 }),
|
||||
deferAutoExtraction(reason, meta = {}) {
|
||||
deferred.push({
|
||||
reason,
|
||||
messageId: Number.isFinite(Number(meta?.messageId))
|
||||
? Number(meta.messageId)
|
||||
: null,
|
||||
});
|
||||
},
|
||||
setPendingRecallSendIntent() {},
|
||||
getContext: () => ({
|
||||
chat: [
|
||||
{ is_user: true, mes: "u1" },
|
||||
{ is_user: false, mes: "a1" },
|
||||
],
|
||||
}),
|
||||
isAssistantChatMessage(message) {
|
||||
return Boolean(message) && !message.is_user && !message.is_system;
|
||||
},
|
||||
runExtraction: async () => {
|
||||
runExtractionCalls += 1;
|
||||
},
|
||||
console: {
|
||||
error() {},
|
||||
},
|
||||
notifyExtractionIssue() {},
|
||||
refreshPersistedRecallMessageUi() {},
|
||||
},
|
||||
1,
|
||||
"assistant",
|
||||
);
|
||||
|
||||
await waitForTick();
|
||||
|
||||
assert.equal(runExtractionCalls, 0);
|
||||
assert.deepEqual(deferred, [
|
||||
{
|
||||
reason: "generation-running",
|
||||
messageId: 1,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function testGenerationEndedResumesPendingAutoExtractionAfterSettle() {
|
||||
const harness = await createGenerationRecallHarness();
|
||||
harness.chat = [
|
||||
{ is_user: true, mes: "u1" },
|
||||
{ is_user: false, mes: "streaming response" },
|
||||
];
|
||||
harness.result.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
dbReady: true,
|
||||
chatId: "chat-main",
|
||||
});
|
||||
|
||||
harness.result.onGenerationStarted("normal", {}, false);
|
||||
harness.invokeOnMessageReceived(1, "assistant");
|
||||
await waitForTick();
|
||||
|
||||
assert.equal(harness.runExtractionCalls.length, 0);
|
||||
assert.equal(
|
||||
harness.result.getPendingAutoExtraction().reason,
|
||||
"generation-running",
|
||||
);
|
||||
|
||||
harness.result.onGenerationEnded();
|
||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||
|
||||
assert.equal(harness.runExtractionCalls.length, 1);
|
||||
harness.result.clearPendingAutoExtraction();
|
||||
}
|
||||
|
||||
async function testAutoExtractionDefersWhenGraphNotReady() {
|
||||
const deferredReasons = [];
|
||||
const statuses = [];
|
||||
@@ -5671,6 +5754,8 @@ await testMessageSentFallsBackToLatestUserWhenHostMessageIdInvalid();
|
||||
await testUserMessageRenderedRefreshesRecallUiAfterRealDomRender();
|
||||
await testCharacterMessageRenderedRefreshesRecallUiAfterAssistantRender();
|
||||
await testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask();
|
||||
await testMessageReceivedDefersExtractionDuringHostGeneration();
|
||||
await testGenerationEndedResumesPendingAutoExtractionAfterSettle();
|
||||
await testAutoExtractionDefersWhenGraphNotReady();
|
||||
await testAutoExtractionDefersWhenAlreadyExtracting();
|
||||
await testAutoExtractionDefersWhenHistoryRecoveryBusy();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
findJournalRecoveryPoint,
|
||||
normalizeGraphRuntimeState,
|
||||
PROCESSED_MESSAGE_HASH_VERSION,
|
||||
rebindProcessedHistoryStateToChat,
|
||||
rollbackBatch,
|
||||
snapshotProcessedMessageHashes,
|
||||
} from "../runtime-state.js";
|
||||
@@ -94,6 +95,28 @@ assert.equal(migratedGraph.historyState.processedMessageHashesNeedRefresh, true)
|
||||
const migratedDetection = detectHistoryMutation(chat, migratedGraph.historyState);
|
||||
assert.equal(migratedDetection.dirty, false);
|
||||
|
||||
const importedGraph = normalizeGraphRuntimeState({
|
||||
historyState: {
|
||||
chatId: "chat-history-test",
|
||||
lastProcessedAssistantFloor: 99,
|
||||
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
||||
processedMessageHashes: {},
|
||||
processedMessageHashesNeedRefresh: true,
|
||||
},
|
||||
});
|
||||
const reboundResult = rebindProcessedHistoryStateToChat(importedGraph, chat, [
|
||||
1,
|
||||
3,
|
||||
]);
|
||||
assert.equal(reboundResult.rebound, true);
|
||||
assert.equal(reboundResult.lastProcessedAssistantFloor, 3);
|
||||
assert.equal(reboundResult.clamped, true);
|
||||
assert.equal(importedGraph.historyState.processedMessageHashesNeedRefresh, false);
|
||||
assert.deepEqual(
|
||||
importedGraph.historyState.processedMessageHashes,
|
||||
snapshotProcessedMessageHashes(chat, 3),
|
||||
);
|
||||
|
||||
const truncatedChat = chat.slice(0, 2);
|
||||
const truncatedDetection = detectHistoryMutation(truncatedChat, {
|
||||
lastProcessedAssistantFloor: 3,
|
||||
|
||||
Reference in New Issue
Block a user