mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
3404 lines
98 KiB
JavaScript
3404 lines
98 KiB
JavaScript
import assert from "node:assert/strict";
|
||
import fs from "node:fs/promises";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import vm from "node:vm";
|
||
|
||
import {
|
||
buildBmeDbName,
|
||
buildGraphFromSnapshot,
|
||
buildPersistDelta,
|
||
buildSnapshotFromGraph,
|
||
evaluatePersistNativeDeltaGate,
|
||
} from "../sync/bme-db.js";
|
||
import { onMessageReceivedController } from "../host/event-binding.js";
|
||
import {
|
||
buildGraphCommitMarker,
|
||
buildGraphChatStateSnapshot,
|
||
canUseGraphChatState,
|
||
detectIndexedDbSnapshotCommitMarkerMismatch,
|
||
cloneGraphForPersistence,
|
||
cloneRuntimeDebugValue,
|
||
findGraphShadowSnapshotByIntegrity,
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
getAcceptedCommitMarkerRevision,
|
||
getGraphPersistedRevision,
|
||
getGraphIdentityAliasCandidates,
|
||
getGraphPersistenceMeta,
|
||
GRAPH_COMMIT_MARKER_KEY,
|
||
getGraphShadowSnapshotStorageKey,
|
||
GRAPH_LOAD_PENDING_CHAT_ID,
|
||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||
GRAPH_LOAD_STATES,
|
||
GRAPH_METADATA_KEY,
|
||
GRAPH_PERSISTENCE_META_KEY,
|
||
GRAPH_PERSISTENCE_SESSION_ID,
|
||
GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX,
|
||
GRAPH_STARTUP_RECONCILE_DELAYS_MS,
|
||
MODULE_NAME,
|
||
normalizeGraphCommitMarker,
|
||
readGraphCommitMarker,
|
||
readGraphChatStateSnapshot,
|
||
readGraphShadowSnapshot,
|
||
rememberGraphIdentityAlias,
|
||
removeGraphShadowSnapshot,
|
||
resolveGraphIdentityAliasByHostChatId,
|
||
shouldPreferShadowSnapshotOverOfficial,
|
||
stampGraphPersistenceMeta,
|
||
writeChatMetadataPatch,
|
||
writeGraphChatStateSnapshot,
|
||
writeGraphShadowSnapshot,
|
||
} from "../graph/graph-persistence.js";
|
||
import {
|
||
createEmptyGraph,
|
||
deserializeGraph,
|
||
getGraphStats,
|
||
getNode,
|
||
serializeGraph,
|
||
} from "../graph/graph.js";
|
||
import {
|
||
buildPersistedRecallRecord,
|
||
readPersistedRecallFromUserMessage,
|
||
} from "../retrieval/recall-persistence.js";
|
||
import { getNodeDisplayName } from "../graph/node-labels.js";
|
||
import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js";
|
||
import {
|
||
defaultSettings,
|
||
getPersistedSettingsSnapshot,
|
||
mergePersistedSettings,
|
||
} from "../runtime/settings-defaults.js";
|
||
import {
|
||
clampFloat,
|
||
clampInt,
|
||
createGraphPersistenceState,
|
||
createRecallInputRecord,
|
||
createRecallRunResult,
|
||
createUiStatus,
|
||
formatRecallContextLine,
|
||
getStageNoticeDuration,
|
||
getStageNoticeTitle,
|
||
hashRecallInput,
|
||
isFreshRecallInputRecord,
|
||
normalizeRecallInputText,
|
||
normalizeStageNoticeLevel,
|
||
} from "../ui/ui-status.js";
|
||
|
||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||
const indexPath = path.resolve(moduleDir, "../index.js");
|
||
const indexSource = await fs.readFile(indexPath, "utf8");
|
||
|
||
function extractSnippet(startMarker, endMarker) {
|
||
const start = indexSource.indexOf(startMarker);
|
||
const end = indexSource.indexOf(endMarker);
|
||
if (start < 0 || end < 0 || end <= start) {
|
||
throw new Error(`无法提取 index.js 片段: ${startMarker} -> ${endMarker}`);
|
||
}
|
||
return indexSource.slice(start, end).replace(/^export\s+/gm, "");
|
||
}
|
||
|
||
const persistencePrelude = extractSnippet(
|
||
'const SERVER_SETTINGS_FILENAME = "st-bme-settings.json";',
|
||
"function clearInjectionState(options = {}) {",
|
||
);
|
||
const persistenceCore = extractSnippet(
|
||
"function loadGraphFromChat(options = {}) {",
|
||
"function handleGraphShadowSnapshotPageHide() {",
|
||
);
|
||
const messageSnippet = extractSnippet(
|
||
'function onMessageReceived(messageId = null, type = "") {',
|
||
"async function onViewGraph() {",
|
||
);
|
||
|
||
function createSessionStorage(seed = null) {
|
||
const store = seed instanceof Map ? seed : new Map();
|
||
return {
|
||
__store: store,
|
||
get length() {
|
||
return store.size;
|
||
},
|
||
key(index) {
|
||
return Array.from(store.keys())[Number(index)] ?? null;
|
||
},
|
||
getItem(key) {
|
||
return store.has(key) ? store.get(key) : null;
|
||
},
|
||
setItem(key, value) {
|
||
store.set(String(key), String(value));
|
||
},
|
||
removeItem(key) {
|
||
store.delete(String(key));
|
||
},
|
||
};
|
||
}
|
||
|
||
function createLocalStorage(seed = null) {
|
||
const store = seed instanceof Map ? seed : new Map();
|
||
return {
|
||
__store: store,
|
||
getItem(key) {
|
||
return store.has(key) ? store.get(key) : null;
|
||
},
|
||
setItem(key, value) {
|
||
store.set(String(key), String(value));
|
||
},
|
||
removeItem(key) {
|
||
store.delete(String(key));
|
||
},
|
||
};
|
||
}
|
||
|
||
function createMeaningfulGraph(chatId = "chat-test", suffix = "base") {
|
||
const graph = createEmptyGraph();
|
||
graph.historyState.chatId = chatId;
|
||
graph.historyState.extractionCount = 3;
|
||
graph.historyState.lastProcessedAssistantFloor = 6;
|
||
graph.lastProcessedSeq = 6;
|
||
graph.lastRecallResult = [{ id: `recall-${suffix}` }];
|
||
graph.nodes.push({
|
||
id: `node-${suffix}`,
|
||
type: "event",
|
||
fields: {
|
||
title: `事件-${suffix}`,
|
||
summary: `摘要-${suffix}`,
|
||
},
|
||
seq: 6,
|
||
seqRange: [6, 6],
|
||
archived: false,
|
||
embedding: null,
|
||
importance: 5,
|
||
accessCount: 0,
|
||
lastAccessTime: Date.now(),
|
||
createdTime: Date.now(),
|
||
level: 0,
|
||
parentId: null,
|
||
childIds: [],
|
||
prevId: null,
|
||
nextId: null,
|
||
clusters: [],
|
||
});
|
||
return normalizeGraphRuntimeState(graph, chatId);
|
||
}
|
||
|
||
function stampPersistedGraph(
|
||
graph,
|
||
{
|
||
revision = 1,
|
||
integrity = "",
|
||
chatId = graph?.historyState?.chatId || "",
|
||
reason = "test",
|
||
} = {},
|
||
) {
|
||
graph.__stBmePersistence = {
|
||
revision,
|
||
integrity,
|
||
chatId,
|
||
reason,
|
||
updatedAt: new Date().toISOString(),
|
||
sessionId: "test-session",
|
||
};
|
||
return graph;
|
||
}
|
||
|
||
async function createGraphPersistenceHarness({
|
||
chatId = "chat-test",
|
||
chatMetadata = undefined,
|
||
sessionStore = null,
|
||
localStore = null,
|
||
globalChatId = "",
|
||
characterId = "",
|
||
groupId = null,
|
||
indexedDbSnapshot = null,
|
||
indexedDbSnapshots = null,
|
||
chat = [],
|
||
} = {}) {
|
||
const timers = new Map();
|
||
let nextTimerId = 1;
|
||
const storage = createSessionStorage(sessionStore);
|
||
const localStorage = createLocalStorage(localStore);
|
||
const indexedDbSnapshotMap =
|
||
indexedDbSnapshots instanceof Map
|
||
? new Map(indexedDbSnapshots)
|
||
: new Map(
|
||
Object.entries(
|
||
indexedDbSnapshots &&
|
||
typeof indexedDbSnapshots === "object" &&
|
||
!Array.isArray(indexedDbSnapshots)
|
||
? indexedDbSnapshots
|
||
: {},
|
||
),
|
||
);
|
||
|
||
if (indexedDbSnapshot) {
|
||
const primaryChatId = String(chatId || globalChatId || "");
|
||
if (primaryChatId) {
|
||
indexedDbSnapshotMap.set(primaryChatId, structuredClone(indexedDbSnapshot));
|
||
}
|
||
}
|
||
|
||
function buildEmptyIndexedDbSnapshot(targetChatId = "") {
|
||
return {
|
||
meta: { revision: 0, chatId: String(targetChatId || "") },
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||
};
|
||
}
|
||
|
||
function getIndexedDbSnapshotForChat(targetChatId = "") {
|
||
const normalizedChatId = String(targetChatId || "");
|
||
if (normalizedChatId && indexedDbSnapshotMap.has(normalizedChatId)) {
|
||
return structuredClone(indexedDbSnapshotMap.get(normalizedChatId));
|
||
}
|
||
|
||
if (
|
||
normalizedChatId &&
|
||
indexedDbSnapshot &&
|
||
!indexedDbSnapshotMap.size &&
|
||
normalizedChatId === String(chatId || globalChatId || "")
|
||
) {
|
||
return structuredClone(indexedDbSnapshot);
|
||
}
|
||
|
||
return buildEmptyIndexedDbSnapshot(normalizedChatId);
|
||
}
|
||
|
||
function setIndexedDbSnapshotForChat(targetChatId = "", snapshot = null) {
|
||
const normalizedChatId = String(targetChatId || "");
|
||
if (!normalizedChatId) return;
|
||
if (!snapshot) {
|
||
indexedDbSnapshotMap.delete(normalizedChatId);
|
||
return;
|
||
}
|
||
indexedDbSnapshotMap.set(normalizedChatId, structuredClone(snapshot));
|
||
}
|
||
|
||
function commitIndexedDbDelta(targetChatId = "", delta = {}, options = {}) {
|
||
const normalizedChatId = String(targetChatId || "");
|
||
const currentSnapshot = getIndexedDbSnapshotForChat(normalizedChatId);
|
||
const now = Date.now();
|
||
|
||
const nodeMap = new Map(
|
||
(Array.isArray(currentSnapshot?.nodes) ? currentSnapshot.nodes : [])
|
||
.filter((record) => record?.id)
|
||
.map((record) => [String(record.id), structuredClone(record)]),
|
||
);
|
||
const edgeMap = new Map(
|
||
(Array.isArray(currentSnapshot?.edges) ? currentSnapshot.edges : [])
|
||
.filter((record) => record?.id)
|
||
.map((record) => [String(record.id), structuredClone(record)]),
|
||
);
|
||
const tombstoneMap = new Map(
|
||
(Array.isArray(currentSnapshot?.tombstones) ? currentSnapshot.tombstones : [])
|
||
.filter((record) => record?.id)
|
||
.map((record) => [String(record.id), structuredClone(record)]),
|
||
);
|
||
|
||
for (const edgeId of Array.isArray(delta?.deleteEdgeIds) ? delta.deleteEdgeIds : []) {
|
||
edgeMap.delete(String(edgeId));
|
||
}
|
||
for (const nodeId of Array.isArray(delta?.deleteNodeIds) ? delta.deleteNodeIds : []) {
|
||
nodeMap.delete(String(nodeId));
|
||
}
|
||
for (const record of Array.isArray(delta?.upsertNodes) ? delta.upsertNodes : []) {
|
||
if (!record?.id) continue;
|
||
nodeMap.set(String(record.id), structuredClone(record));
|
||
}
|
||
for (const record of Array.isArray(delta?.upsertEdges) ? delta.upsertEdges : []) {
|
||
if (!record?.id) continue;
|
||
edgeMap.set(String(record.id), structuredClone(record));
|
||
}
|
||
for (const record of Array.isArray(delta?.tombstones) ? delta.tombstones : []) {
|
||
if (!record?.id) continue;
|
||
tombstoneMap.set(String(record.id), structuredClone(record));
|
||
}
|
||
|
||
const runtimeMetaPatch =
|
||
delta?.runtimeMetaPatch &&
|
||
typeof delta.runtimeMetaPatch === "object" &&
|
||
!Array.isArray(delta.runtimeMetaPatch)
|
||
? structuredClone(delta.runtimeMetaPatch)
|
||
: {};
|
||
const shouldMarkSyncDirty = options?.markSyncDirty !== false;
|
||
const nextRevision = Math.max(
|
||
Number(currentSnapshot?.meta?.revision || 0) + 1,
|
||
Number(options?.requestedRevision || 0),
|
||
);
|
||
const nextState = {
|
||
lastProcessedFloor: Number.isFinite(Number(runtimeMetaPatch.lastProcessedFloor))
|
||
? Number(runtimeMetaPatch.lastProcessedFloor)
|
||
: Number(currentSnapshot?.state?.lastProcessedFloor ?? -1),
|
||
extractionCount: Number.isFinite(Number(runtimeMetaPatch.extractionCount))
|
||
? Number(runtimeMetaPatch.extractionCount)
|
||
: Number(currentSnapshot?.state?.extractionCount ?? 0),
|
||
};
|
||
const nextSnapshot = {
|
||
meta: {
|
||
...(currentSnapshot?.meta || {}),
|
||
...runtimeMetaPatch,
|
||
chatId: normalizedChatId,
|
||
revision: nextRevision,
|
||
lastModified: now,
|
||
lastMutationReason: String(options?.reason || "commitDelta"),
|
||
syncDirty: shouldMarkSyncDirty,
|
||
syncDirtyReason: shouldMarkSyncDirty
|
||
? String(options?.reason || "commitDelta")
|
||
: "",
|
||
nodeCount: nodeMap.size,
|
||
edgeCount: edgeMap.size,
|
||
tombstoneCount: tombstoneMap.size,
|
||
},
|
||
nodes: Array.from(nodeMap.values()),
|
||
edges: Array.from(edgeMap.values()),
|
||
tombstones: Array.from(tombstoneMap.values()),
|
||
state: nextState,
|
||
};
|
||
|
||
setIndexedDbSnapshotForChat(normalizedChatId, nextSnapshot);
|
||
runtimeContext.__indexedDbSnapshot =
|
||
getIndexedDbSnapshotForChat(normalizedChatId);
|
||
|
||
return {
|
||
revision: nextRevision,
|
||
lastModified: now,
|
||
imported: {
|
||
nodes: nodeMap.size,
|
||
edges: edgeMap.size,
|
||
tombstones: tombstoneMap.size,
|
||
},
|
||
delta: {
|
||
upsertNodes: Array.isArray(delta?.upsertNodes) ? delta.upsertNodes.length : 0,
|
||
upsertEdges: Array.isArray(delta?.upsertEdges) ? delta.upsertEdges.length : 0,
|
||
deleteNodeIds: Array.isArray(delta?.deleteNodeIds) ? delta.deleteNodeIds.length : 0,
|
||
deleteEdgeIds: Array.isArray(delta?.deleteEdgeIds) ? delta.deleteEdgeIds.length : 0,
|
||
tombstones: Array.isArray(delta?.tombstones) ? delta.tombstones.length : 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
const runtimeContext = {
|
||
console,
|
||
Date,
|
||
Math,
|
||
JSON,
|
||
Object,
|
||
Array,
|
||
String,
|
||
Number,
|
||
Boolean,
|
||
structuredClone,
|
||
result: null,
|
||
__indexedDbSnapshot: getIndexedDbSnapshotForChat(
|
||
String(chatId || globalChatId || ""),
|
||
),
|
||
__indexedDbSnapshots: indexedDbSnapshotMap,
|
||
sessionStorage: storage,
|
||
localStorage,
|
||
extension_settings: {
|
||
[MODULE_NAME]: {},
|
||
},
|
||
defaultSettings,
|
||
getPersistedSettingsSnapshot,
|
||
mergePersistedSettings,
|
||
migrateLegacyTaskProfiles(settings = {}) {
|
||
return {
|
||
taskProfilesVersion: Number(settings?.taskProfilesVersion || 0),
|
||
taskProfiles:
|
||
settings?.taskProfiles && typeof settings.taskProfiles === "object"
|
||
? settings.taskProfiles
|
||
: {},
|
||
};
|
||
},
|
||
migratePerTaskRegexToGlobal(settings = {}) {
|
||
return {
|
||
changed: false,
|
||
settings,
|
||
};
|
||
},
|
||
setTimeout(fn, delay) {
|
||
const id = nextTimerId++;
|
||
timers.set(id, { fn, delay });
|
||
return id;
|
||
},
|
||
clearTimeout(id) {
|
||
timers.delete(id);
|
||
},
|
||
queueMicrotask(fn) {
|
||
fn();
|
||
},
|
||
toastr: {
|
||
info() {},
|
||
warning() {},
|
||
error() {},
|
||
success() {},
|
||
},
|
||
window: {
|
||
addEventListener() {},
|
||
removeEventListener() {},
|
||
},
|
||
document: {
|
||
visibilityState: "visible",
|
||
getElementById() {
|
||
return null;
|
||
},
|
||
},
|
||
SillyTavern: {
|
||
getCurrentChatId() {
|
||
return runtimeContext.__globalChatId;
|
||
},
|
||
},
|
||
__globalChatId: String(globalChatId || ""),
|
||
Dexie: {
|
||
async exists(dbName = "") {
|
||
return Array.from(indexedDbSnapshotMap.keys()).some(
|
||
(candidateChatId) => buildBmeDbName(candidateChatId) === String(dbName),
|
||
);
|
||
},
|
||
async getDatabaseNames() {
|
||
return Array.from(indexedDbSnapshotMap.keys()).map((candidateChatId) =>
|
||
buildBmeDbName(candidateChatId),
|
||
);
|
||
},
|
||
},
|
||
async ensureDexieLoaded() {
|
||
return runtimeContext.Dexie;
|
||
},
|
||
refreshPanelLiveState() {
|
||
runtimeContext.__panelRefreshCount += 1;
|
||
},
|
||
__panelRefreshCount: 0,
|
||
getLastProcessedAssistantFloor() {
|
||
const historyFloor = Number(
|
||
runtimeContext.currentGraph?.historyState?.lastProcessedAssistantFloor,
|
||
);
|
||
if (Number.isFinite(historyFloor)) {
|
||
return historyFloor;
|
||
}
|
||
const legacySeq = Number(runtimeContext.currentGraph?.lastProcessedSeq);
|
||
if (Number.isFinite(legacySeq)) return legacySeq;
|
||
return -1;
|
||
},
|
||
createEmptyGraph,
|
||
normalizeGraphRuntimeState,
|
||
serializeGraph,
|
||
deserializeGraph,
|
||
getGraphStats,
|
||
getNode,
|
||
getNodeDisplayName,
|
||
createUiStatus,
|
||
createGraphPersistenceState,
|
||
createRecallInputRecord,
|
||
createRecallRunResult,
|
||
normalizeStageNoticeLevel,
|
||
getStageNoticeTitle,
|
||
getStageNoticeDuration,
|
||
normalizeRecallInputText,
|
||
hashRecallInput,
|
||
isFreshRecallInputRecord,
|
||
clampInt,
|
||
clampFloat,
|
||
formatRecallContextLine,
|
||
readPersistedRecallFromUserMessage,
|
||
cloneGraphForPersistence,
|
||
buildGraphCommitMarker,
|
||
buildGraphChatStateSnapshot,
|
||
canUseGraphChatState,
|
||
cloneRuntimeDebugValue,
|
||
detectIndexedDbSnapshotCommitMarkerMismatch,
|
||
onMessageReceivedController,
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
getAcceptedCommitMarkerRevision,
|
||
getGraphPersistenceMeta,
|
||
getGraphPersistedRevision,
|
||
getGraphIdentityAliasCandidates,
|
||
GRAPH_COMMIT_MARKER_KEY,
|
||
getGraphShadowSnapshotStorageKey,
|
||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||
GRAPH_LOAD_PENDING_CHAT_ID,
|
||
GRAPH_LOAD_STATES,
|
||
GRAPH_METADATA_KEY,
|
||
GRAPH_PERSISTENCE_META_KEY,
|
||
GRAPH_PERSISTENCE_SESSION_ID,
|
||
GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX,
|
||
GRAPH_STARTUP_RECONCILE_DELAYS_MS,
|
||
MODULE_NAME,
|
||
findGraphShadowSnapshotByIntegrity,
|
||
normalizeGraphCommitMarker,
|
||
readGraphCommitMarker,
|
||
readGraphChatStateSnapshot,
|
||
readGraphShadowSnapshot,
|
||
rememberGraphIdentityAlias,
|
||
removeGraphShadowSnapshot,
|
||
resolveGraphIdentityAliasByHostChatId,
|
||
shouldPreferShadowSnapshotOverOfficial,
|
||
stampGraphPersistenceMeta,
|
||
writeChatMetadataPatch,
|
||
writeGraphChatStateSnapshot,
|
||
writeGraphShadowSnapshot,
|
||
// Shadow snapshot functions need VM-local sessionStorage overrides
|
||
// because imported versions use the outer globalThis (no sessionStorage)
|
||
rememberGraphIdentityAlias({
|
||
integrity = "",
|
||
hostChatId = "",
|
||
persistenceChatId = "",
|
||
} = {}) {
|
||
const normalizedIntegrity = String(integrity || "").trim();
|
||
if (!normalizedIntegrity) return null;
|
||
|
||
const normalizedHostChatId = String(hostChatId || "").trim();
|
||
const normalizedPersistenceChatId = String(
|
||
persistenceChatId || normalizedIntegrity,
|
||
).trim();
|
||
let registry = { byIntegrity: {} };
|
||
try {
|
||
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw);
|
||
if (
|
||
parsed?.byIntegrity &&
|
||
typeof parsed.byIntegrity === "object" &&
|
||
!Array.isArray(parsed.byIntegrity)
|
||
) {
|
||
registry = { byIntegrity: parsed.byIntegrity };
|
||
}
|
||
}
|
||
} catch {
|
||
registry = { byIntegrity: {} };
|
||
}
|
||
|
||
const current = registry.byIntegrity[normalizedIntegrity] || {};
|
||
const hostChatIds = Array.from(
|
||
new Set(
|
||
[
|
||
normalizedHostChatId,
|
||
...(Array.isArray(current.hostChatIds) ? current.hostChatIds : []),
|
||
].filter(Boolean),
|
||
),
|
||
);
|
||
const next = {
|
||
integrity: normalizedIntegrity,
|
||
persistenceChatId: normalizedPersistenceChatId,
|
||
hostChatIds,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
registry.byIntegrity[normalizedIntegrity] = next;
|
||
localStorage.setItem(
|
||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||
JSON.stringify(registry),
|
||
);
|
||
return next;
|
||
},
|
||
resolveGraphIdentityAliasByHostChatId(hostChatId = "") {
|
||
const normalizedHostChatId = String(hostChatId || "").trim();
|
||
if (!normalizedHostChatId) return "";
|
||
try {
|
||
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : { byIntegrity: {} };
|
||
let best = "";
|
||
let bestUpdatedAt = "";
|
||
for (const value of Object.values(parsed.byIntegrity || {})) {
|
||
const hostChatIds = Array.isArray(value?.hostChatIds)
|
||
? value.hostChatIds.map((item) => String(item || "").trim())
|
||
: [];
|
||
if (!hostChatIds.includes(normalizedHostChatId)) continue;
|
||
const persistenceChatId = String(
|
||
value?.persistenceChatId || value?.integrity || "",
|
||
).trim();
|
||
if (!persistenceChatId) continue;
|
||
const updatedAt = String(value?.updatedAt || "");
|
||
if (!best || updatedAt > bestUpdatedAt) {
|
||
best = persistenceChatId;
|
||
bestUpdatedAt = updatedAt;
|
||
}
|
||
}
|
||
return best;
|
||
} catch {
|
||
return "";
|
||
}
|
||
},
|
||
getGraphIdentityAliasCandidates({
|
||
integrity = "",
|
||
hostChatId = "",
|
||
persistenceChatId = "",
|
||
} = {}) {
|
||
const normalizedIntegrity = String(integrity || "").trim();
|
||
const normalizedHostChatId = String(hostChatId || "").trim();
|
||
const normalizedPersistenceChatId = String(
|
||
persistenceChatId || "",
|
||
).trim();
|
||
const candidates = [];
|
||
const seen = new Set();
|
||
const addCandidate = (value) => {
|
||
const normalized = String(value || "").trim();
|
||
if (!normalized || seen.has(normalized)) return;
|
||
seen.add(normalized);
|
||
candidates.push(normalized);
|
||
};
|
||
|
||
try {
|
||
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : { byIntegrity: {} };
|
||
if (normalizedIntegrity) {
|
||
const value = parsed.byIntegrity?.[normalizedIntegrity] || {};
|
||
addCandidate(value?.persistenceChatId || value?.integrity || "");
|
||
for (const candidate of Array.isArray(value?.hostChatIds)
|
||
? value.hostChatIds
|
||
: []) {
|
||
addCandidate(candidate);
|
||
}
|
||
} else if (normalizedHostChatId) {
|
||
addCandidate(
|
||
runtimeContext.resolveGraphIdentityAliasByHostChatId(
|
||
normalizedHostChatId,
|
||
),
|
||
);
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
addCandidate(normalizedHostChatId);
|
||
addCandidate(normalizedPersistenceChatId);
|
||
return candidates;
|
||
},
|
||
readGraphShadowSnapshot(chatId = "") {
|
||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||
if (!key) return null;
|
||
try {
|
||
const raw = storage.getItem(key);
|
||
if (!raw) return null;
|
||
const snap = JSON.parse(raw);
|
||
if (
|
||
!snap ||
|
||
String(snap.chatId || "") !== String(chatId || "") ||
|
||
typeof snap.serializedGraph !== "string" ||
|
||
!snap.serializedGraph
|
||
)
|
||
return null;
|
||
return {
|
||
chatId: String(snap.chatId || ""),
|
||
revision: Number.isFinite(snap.revision) ? snap.revision : 0,
|
||
serializedGraph: snap.serializedGraph,
|
||
updatedAt: String(snap.updatedAt || ""),
|
||
reason: String(snap.reason || ""),
|
||
integrity: String(snap.integrity || ""),
|
||
persistedChatId: String(snap.persistedChatId || ""),
|
||
debugReason: String(snap.debugReason || snap.reason || ""),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
findGraphShadowSnapshotByIntegrity(integrity = "", { excludeChatIds = [] } = {}) {
|
||
const normalizedIntegrity = String(integrity || "").trim();
|
||
if (!normalizedIntegrity) return null;
|
||
const excluded = new Set(
|
||
(Array.isArray(excludeChatIds) ? excludeChatIds : [])
|
||
.map((value) => String(value || "").trim())
|
||
.filter(Boolean),
|
||
);
|
||
let best = null;
|
||
for (const key of storage.__store.keys()) {
|
||
if (!String(key || "").startsWith(GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX)) {
|
||
continue;
|
||
}
|
||
try {
|
||
const snap = JSON.parse(storage.getItem(key));
|
||
if (
|
||
!snap ||
|
||
String(snap.integrity || "") !== normalizedIntegrity ||
|
||
typeof snap.serializedGraph !== "string" ||
|
||
!snap.serializedGraph
|
||
) {
|
||
continue;
|
||
}
|
||
const normalizedChatId = String(snap.chatId || "").trim();
|
||
if (!normalizedChatId || excluded.has(normalizedChatId)) {
|
||
continue;
|
||
}
|
||
if (
|
||
!best ||
|
||
Number(snap.revision || 0) > Number(best.revision || 0) ||
|
||
(Number(snap.revision || 0) === Number(best.revision || 0) &&
|
||
String(snap.updatedAt || "") > String(best.updatedAt || ""))
|
||
) {
|
||
best = {
|
||
chatId: normalizedChatId,
|
||
revision: Number.isFinite(snap.revision) ? snap.revision : 0,
|
||
serializedGraph: snap.serializedGraph,
|
||
updatedAt: String(snap.updatedAt || ""),
|
||
reason: String(snap.reason || ""),
|
||
integrity: String(snap.integrity || ""),
|
||
persistedChatId: String(snap.persistedChatId || ""),
|
||
debugReason: String(snap.debugReason || snap.reason || ""),
|
||
};
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
return best;
|
||
},
|
||
writeGraphShadowSnapshot(
|
||
chatId = "",
|
||
graph = null,
|
||
{ revision = 0, reason = "", integrity = "", debugReason = "" } = {},
|
||
) {
|
||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||
if (!key || !graph) return false;
|
||
const persistedMeta = getGraphPersistenceMeta(graph) || {};
|
||
try {
|
||
storage.setItem(
|
||
key,
|
||
JSON.stringify({
|
||
chatId: String(chatId || ""),
|
||
revision: Number.isFinite(revision) ? revision : 0,
|
||
serializedGraph: serializeGraph(graph),
|
||
updatedAt: new Date().toISOString(),
|
||
reason: String(reason || ""),
|
||
integrity: String(integrity || persistedMeta.integrity || ""),
|
||
persistedChatId: String(persistedMeta.chatId || ""),
|
||
debugReason: String(debugReason || reason || ""),
|
||
}),
|
||
);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
removeGraphShadowSnapshot(chatId = "") {
|
||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||
if (!key) return false;
|
||
try {
|
||
storage.removeItem(key);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
},
|
||
createDefaultTaskProfiles() {
|
||
return {
|
||
extract: { activeProfileId: "default", profiles: [] },
|
||
recall: { activeProfileId: "default", profiles: [] },
|
||
compress: { activeProfileId: "default", profiles: [] },
|
||
synopsis: { activeProfileId: "default", profiles: [] },
|
||
reflection: { activeProfileId: "default", profiles: [] },
|
||
};
|
||
},
|
||
getContext() {
|
||
return runtimeContext.__chatContext;
|
||
},
|
||
async saveMetadata() {
|
||
runtimeContext.__globalImmediateSaveCalls += 1;
|
||
},
|
||
saveMetadataDebounced() {
|
||
runtimeContext.__globalSaveCalls += 1;
|
||
},
|
||
__globalSaveCalls: 0,
|
||
__globalImmediateSaveCalls: 0,
|
||
isAssistantChatMessage() {
|
||
return false;
|
||
},
|
||
isFreshRecallInputRecord() {
|
||
return true;
|
||
},
|
||
notifyExtractionIssue() {},
|
||
debugDebug() {},
|
||
debugLog() {},
|
||
async runExtraction() {},
|
||
getRequestHeaders() {
|
||
return {};
|
||
},
|
||
__syncNowCalls: [],
|
||
async syncNow(chatId, options = {}) {
|
||
runtimeContext.__syncNowCalls.push({
|
||
chatId,
|
||
options: {
|
||
reason: String(options?.reason || ""),
|
||
trigger: String(options?.trigger || ""),
|
||
},
|
||
});
|
||
return { synced: true, chatId, reason: String(options?.reason || "") };
|
||
},
|
||
__chatContext: {
|
||
chatId,
|
||
chatMetadata,
|
||
characterId,
|
||
groupId,
|
||
chat,
|
||
__chatStateStore: new Map(),
|
||
updateChatMetadata(patch) {
|
||
const base =
|
||
this.chatMetadata &&
|
||
typeof this.chatMetadata === "object" &&
|
||
!Array.isArray(this.chatMetadata)
|
||
? this.chatMetadata
|
||
: {};
|
||
this.chatMetadata = {
|
||
...base,
|
||
...(patch || {}),
|
||
};
|
||
},
|
||
saveMetadataDebounced() {
|
||
runtimeContext.__contextSaveCalls += 1;
|
||
},
|
||
async saveMetadata() {
|
||
runtimeContext.__contextImmediateSaveCalls += 1;
|
||
},
|
||
async getChatState(namespace) {
|
||
const key = String(namespace || "").trim().toLowerCase();
|
||
const value = this.__chatStateStore.get(key);
|
||
return value == null ? null : structuredClone(value);
|
||
},
|
||
async updateChatState(namespace, updater) {
|
||
const key = String(namespace || "").trim().toLowerCase();
|
||
if (!key || typeof updater !== "function") {
|
||
return { ok: false, state: null, updated: false };
|
||
}
|
||
const current = this.__chatStateStore.has(key)
|
||
? structuredClone(this.__chatStateStore.get(key))
|
||
: {};
|
||
const next = await updater(structuredClone(current), {
|
||
attempt: 0,
|
||
target: null,
|
||
namespace: key,
|
||
});
|
||
if (next == null) {
|
||
return { ok: true, state: current, updated: false };
|
||
}
|
||
const currentJson = JSON.stringify(current);
|
||
const nextJson = JSON.stringify(next);
|
||
this.__chatStateStore.set(key, structuredClone(next));
|
||
return {
|
||
ok: true,
|
||
state: structuredClone(next),
|
||
updated: currentJson !== nextJson,
|
||
};
|
||
},
|
||
},
|
||
__contextSaveCalls: 0,
|
||
__contextImmediateSaveCalls: 0,
|
||
buildGraphFromSnapshot,
|
||
buildPersistDelta,
|
||
buildSnapshotFromGraph,
|
||
evaluatePersistNativeDeltaGate,
|
||
buildBmeDbName,
|
||
BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto",
|
||
BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB: "indexeddb",
|
||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY: "opfs-primary",
|
||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW: "opfs-shadow",
|
||
detectOpfsSupport: async () => ({
|
||
available: false,
|
||
reason: "opfs-unsupported-in-test",
|
||
}),
|
||
isGraphLocalStorageModeOpfs: (mode = "") =>
|
||
/^opfs-/.test(String(mode || "").trim().toLowerCase()),
|
||
normalizeGraphLocalStorageMode: (mode = "", fallback = "indexeddb") => {
|
||
const normalized = String(mode || "").trim().toLowerCase();
|
||
if (
|
||
normalized === "indexeddb" ||
|
||
normalized === "opfs-shadow" ||
|
||
normalized === "opfs-primary"
|
||
) {
|
||
return normalized;
|
||
}
|
||
return String(fallback || "indexeddb").trim().toLowerCase() || "indexeddb";
|
||
},
|
||
OpfsGraphStore: class {
|
||
constructor(dbChatId = "") {
|
||
this.chatId = String(dbChatId || "");
|
||
this.storeKind = "opfs";
|
||
this.storeMode = "opfs-shadow";
|
||
}
|
||
async open() {}
|
||
async close() {}
|
||
async exportSnapshot() {
|
||
return getIndexedDbSnapshotForChat(this.chatId);
|
||
}
|
||
async commitDelta(delta, options = {}) {
|
||
return commitIndexedDbDelta(this.chatId, delta, options);
|
||
}
|
||
async importSnapshot(snapshot) {
|
||
setIndexedDbSnapshotForChat(this.chatId, snapshot);
|
||
return {
|
||
revision: Number(snapshot?.meta?.revision) || 0,
|
||
};
|
||
}
|
||
async isEmpty() {
|
||
const snapshot = getIndexedDbSnapshotForChat(this.chatId);
|
||
return {
|
||
empty:
|
||
!snapshot ||
|
||
(!snapshot.nodes?.length && !snapshot.edges?.length && !snapshot.tombstones?.length),
|
||
};
|
||
}
|
||
async getRevision() {
|
||
return Number(getIndexedDbSnapshotForChat(this.chatId)?.meta?.revision || 0);
|
||
}
|
||
async getMeta(key, fallbackValue = 0) {
|
||
const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {};
|
||
if (!snapshot?.meta || !(key in snapshot.meta)) {
|
||
return fallbackValue;
|
||
}
|
||
return snapshot.meta[key];
|
||
}
|
||
},
|
||
scheduleUpload() {
|
||
if (runtimeContext.__scheduleUploadShouldThrow) {
|
||
throw new Error("schedule-upload-failed");
|
||
}
|
||
},
|
||
BmeDatabase: class {
|
||
constructor(dbChatId = "") {
|
||
this.chatId = String(dbChatId || "");
|
||
}
|
||
async open() {}
|
||
async close() {}
|
||
async exportSnapshot() {
|
||
return getIndexedDbSnapshotForChat(this.chatId);
|
||
}
|
||
async commitDelta(delta, options = {}) {
|
||
return commitIndexedDbDelta(this.chatId, delta, options);
|
||
}
|
||
async importSnapshot(snapshot) {
|
||
setIndexedDbSnapshotForChat(this.chatId, snapshot);
|
||
return {
|
||
revision: Number(snapshot?.meta?.revision) || 0,
|
||
};
|
||
}
|
||
},
|
||
BmeChatManager: class {
|
||
constructor() {
|
||
this._currentChatId = "";
|
||
}
|
||
_createDb(dbChatId = "") {
|
||
return {
|
||
async exportSnapshot() {
|
||
if (runtimeContext.__indexedDbExportSnapshotShouldThrow) {
|
||
throw new Error("indexeddb-export-failed");
|
||
}
|
||
return getIndexedDbSnapshotForChat(dbChatId);
|
||
},
|
||
async commitDelta(delta, options = {}) {
|
||
return commitIndexedDbDelta(dbChatId, delta, options);
|
||
},
|
||
async importSnapshot(snapshot) {
|
||
setIndexedDbSnapshotForChat(dbChatId, snapshot);
|
||
runtimeContext.__indexedDbSnapshot =
|
||
getIndexedDbSnapshotForChat(dbChatId);
|
||
return {
|
||
revision:
|
||
Number(snapshot?.meta?.revision) ||
|
||
Number(runtimeContext.__indexedDbSnapshot?.meta?.revision) ||
|
||
0,
|
||
};
|
||
},
|
||
async getMeta(key, fallbackValue = 0) {
|
||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||
if (!snapshot?.meta || !(key in snapshot.meta)) {
|
||
return fallbackValue;
|
||
}
|
||
return snapshot.meta[key];
|
||
},
|
||
async getRevision() {
|
||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||
return Number(snapshot?.meta?.revision) || 0;
|
||
},
|
||
async isEmpty() {
|
||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||
const nodes = Array.isArray(snapshot?.nodes)
|
||
? snapshot.nodes.length
|
||
: 0;
|
||
const edges = Array.isArray(snapshot?.edges)
|
||
? snapshot.edges.length
|
||
: 0;
|
||
const tombstones = Array.isArray(snapshot?.tombstones)
|
||
? snapshot.tombstones.length
|
||
: 0;
|
||
return {
|
||
empty: nodes === 0 && edges === 0,
|
||
nodes,
|
||
edges,
|
||
tombstones,
|
||
};
|
||
},
|
||
async importLegacyGraph(graph, options = {}) {
|
||
const revision = Number(options?.revision) || 1;
|
||
const migratedSnapshot = buildSnapshotFromGraph(graph, {
|
||
chatId: dbChatId || runtimeContext.__chatContext?.chatId || "",
|
||
revision,
|
||
meta: {
|
||
migrationCompletedAt: Date.now(),
|
||
migrationSource: "chat_metadata",
|
||
},
|
||
});
|
||
setIndexedDbSnapshotForChat(dbChatId, migratedSnapshot);
|
||
runtimeContext.__indexedDbSnapshot =
|
||
getIndexedDbSnapshotForChat(dbChatId);
|
||
return {
|
||
migrated: true,
|
||
revision,
|
||
imported: {
|
||
nodes: runtimeContext.__indexedDbSnapshot?.nodes?.length || 0,
|
||
edges: runtimeContext.__indexedDbSnapshot?.edges?.length || 0,
|
||
tombstones:
|
||
runtimeContext.__indexedDbSnapshot?.tombstones?.length || 0,
|
||
},
|
||
};
|
||
},
|
||
async markSyncDirty() {
|
||
if (runtimeContext.__markSyncDirtyShouldThrow) {
|
||
throw new Error("mark-sync-dirty-failed");
|
||
}
|
||
},
|
||
};
|
||
}
|
||
async getCurrentDb(dbChatId = this._currentChatId) {
|
||
this._currentChatId = String(dbChatId || this._currentChatId || "");
|
||
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
|
||
this._currentChatId,
|
||
);
|
||
if (runtimeContext.__indexedDbGetCurrentDbShouldThrow) {
|
||
throw new Error("indexeddb-get-current-db-failed");
|
||
}
|
||
return this._createDb(this._currentChatId);
|
||
}
|
||
async switchChat(dbChatId = "") {
|
||
this._currentChatId = String(dbChatId || "");
|
||
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
|
||
this._currentChatId,
|
||
);
|
||
return this._createDb(this._currentChatId);
|
||
}
|
||
async closeCurrent() {}
|
||
},
|
||
};
|
||
|
||
runtimeContext.globalThis = runtimeContext;
|
||
vm.createContext(runtimeContext);
|
||
vm.runInContext(
|
||
[
|
||
persistencePrelude,
|
||
persistenceCore,
|
||
messageSnippet,
|
||
`
|
||
result = {
|
||
GRAPH_LOAD_STATES,
|
||
GRAPH_LOAD_RETRY_DELAYS_MS,
|
||
readRuntimeDebugSnapshot,
|
||
getGraphPersistenceLiveState,
|
||
readGraphShadowSnapshot,
|
||
writeGraphShadowSnapshot,
|
||
removeGraphShadowSnapshot,
|
||
maybeCaptureGraphShadowSnapshot,
|
||
buildPanelOpenLocalStoreRefreshPlan,
|
||
loadGraphFromChat,
|
||
loadGraphFromIndexedDb,
|
||
saveGraphToChat,
|
||
syncGraphLoadFromLiveContext,
|
||
buildBmeSyncRuntimeOptions,
|
||
onMessageReceived,
|
||
applyGraphLoadState,
|
||
maybeFlushQueuedGraphPersist,
|
||
retryPendingGraphPersist,
|
||
persistExtractionBatchResult,
|
||
saveGraphToIndexedDb,
|
||
cloneGraphForPersistence,
|
||
assertRecoveryChatStillActive,
|
||
createAbortError,
|
||
isAbortError,
|
||
setCurrentGraph(graph) {
|
||
currentGraph = graph;
|
||
return currentGraph;
|
||
},
|
||
getCurrentGraph() {
|
||
return currentGraph;
|
||
},
|
||
getLastInjectionContent() {
|
||
return lastInjectionContent;
|
||
},
|
||
getLastRecalledItems() {
|
||
return lastRecalledItems;
|
||
},
|
||
setGraphPersistenceState(patch = {}) {
|
||
graphPersistenceState = {
|
||
...graphPersistenceState,
|
||
...(patch || {}),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
syncGraphPersistenceDebugState();
|
||
return graphPersistenceState;
|
||
},
|
||
getGraphPersistenceState() {
|
||
return graphPersistenceState;
|
||
},
|
||
setLocalStoreCapabilitySnapshot(patch = {}) {
|
||
bmeLocalStoreCapabilitySnapshot = {
|
||
...bmeLocalStoreCapabilitySnapshot,
|
||
...(patch || {}),
|
||
};
|
||
return bmeLocalStoreCapabilitySnapshot;
|
||
},
|
||
setChatContext(nextContext) {
|
||
globalThis.__chatContext = nextContext;
|
||
return globalThis.__chatContext;
|
||
},
|
||
getChatContext() {
|
||
return globalThis.__chatContext;
|
||
},
|
||
setIndexedDbSnapshot(snapshot) {
|
||
const activeChatId =
|
||
String(globalThis.__chatContext?.chatId || globalThis.__globalChatId || "");
|
||
if (activeChatId) {
|
||
globalThis.__indexedDbSnapshots.set(
|
||
activeChatId,
|
||
structuredClone(snapshot),
|
||
);
|
||
}
|
||
globalThis.__indexedDbSnapshot = structuredClone(snapshot);
|
||
},
|
||
getIndexedDbSnapshot() {
|
||
return globalThis.__indexedDbSnapshot;
|
||
},
|
||
setIndexedDbSnapshotForChat(chatId, snapshot) {
|
||
const normalizedChatId = String(chatId || "");
|
||
if (!normalizedChatId) return;
|
||
globalThis.__indexedDbSnapshots.set(
|
||
normalizedChatId,
|
||
structuredClone(snapshot),
|
||
);
|
||
},
|
||
getIndexedDbSnapshotForChat(chatId) {
|
||
const normalizedChatId = String(chatId || "");
|
||
if (!normalizedChatId) return null;
|
||
const snapshot = globalThis.__indexedDbSnapshots.get(normalizedChatId);
|
||
return snapshot ? structuredClone(snapshot) : null;
|
||
},
|
||
};
|
||
`,
|
||
].join("\n"),
|
||
runtimeContext,
|
||
{ filename: indexPath },
|
||
);
|
||
|
||
return {
|
||
api: runtimeContext.result,
|
||
runtimeContext,
|
||
sessionStore: storage.__store,
|
||
};
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chatMetadata: {},
|
||
characterId: "",
|
||
groupId: null,
|
||
chat: [],
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "no-chat-empty-host-state",
|
||
});
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(result.loadState, "no-chat");
|
||
assert.equal(live.loadState, "no-chat");
|
||
assert.equal(live.writesBlocked, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "chat-global",
|
||
chatMetadata: {
|
||
st_bme_graph: createMeaningfulGraph("chat-global", "global"),
|
||
},
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "global-chat-id",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(result.reason, "global-chat-id:metadata-compat-provisional");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.chatId,
|
||
"chat-global",
|
||
);
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, false);
|
||
assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().dualWriteLastResult?.resultCode,
|
||
"graph.load.metadata-compat.provisional",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().dualWriteLastResult?.provisional,
|
||
true,
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().dualWriteLastResult?.reason,
|
||
"global-chat-id:metadata-compat-provisional",
|
||
);
|
||
}
|
||
|
||
{
|
||
const graph = createMeaningfulGraph("chat-recall-ui", "recall-ui");
|
||
graph.nodes[0].id = "restore-node";
|
||
graph.lastRecallResult = [{ id: "restore-node" }];
|
||
stampPersistedGraph(graph, {
|
||
revision: 7,
|
||
chatId: "chat-recall-ui",
|
||
reason: "recall-ui-restore",
|
||
});
|
||
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-recall-ui",
|
||
globalChatId: "chat-recall-ui",
|
||
indexedDbSnapshot: buildSnapshotFromGraph(graph, {
|
||
chatId: "chat-recall-ui",
|
||
revision: 7,
|
||
}),
|
||
chat: [
|
||
{
|
||
is_user: true,
|
||
mes: "用户楼层",
|
||
extra: {
|
||
bme_recall: buildPersistedRecallRecord({
|
||
injectionText: "已持久化的召回注入",
|
||
selectedNodeIds: [],
|
||
nowIso: "2026-01-01T00:00:00.000Z",
|
||
}),
|
||
},
|
||
},
|
||
{
|
||
is_user: false,
|
||
mes: "assistant",
|
||
},
|
||
],
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "indexeddb-recall-ui-restore",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.synced, true);
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||
assert.equal(harness.api.getLastInjectionContent(), "已持久化的召回注入");
|
||
assert.equal(harness.api.getLastRecalledItems().length, 1);
|
||
assert.equal(harness.api.getLastRecalledItems()[0]?.id, "restore-node");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chatMetadata: {},
|
||
});
|
||
const lateGraph = createMeaningfulGraph("chat-late", "late");
|
||
harness.api.setChatContext({
|
||
chatId: "chat-late",
|
||
chatMetadata: {
|
||
st_bme_graph: lateGraph,
|
||
},
|
||
characterId: "char-late",
|
||
groupId: null,
|
||
chat: [{ is_user: true, mes: "late load" }],
|
||
updateChatMetadata(patch) {
|
||
const base =
|
||
this.chatMetadata &&
|
||
typeof this.chatMetadata === "object" &&
|
||
!Array.isArray(this.chatMetadata)
|
||
? this.chatMetadata
|
||
: {};
|
||
this.chatMetadata = {
|
||
...base,
|
||
...(patch || {}),
|
||
};
|
||
},
|
||
saveMetadataDebounced() {},
|
||
});
|
||
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(lateGraph, { chatId: "chat-late", revision: 5 }),
|
||
);
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "late-context-sync",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.synced, true);
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.chatId,
|
||
"chat-late",
|
||
);
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().storagePrimary,
|
||
"indexeddb",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
chatMetadata: {},
|
||
});
|
||
harness.api.setChatContext({
|
||
chatId: "chat-empty-live",
|
||
chatMetadata: {
|
||
integrity: "chat-empty-live-ready",
|
||
},
|
||
characterId: "char-empty-live",
|
||
groupId: null,
|
||
chat: [{ is_user: true, mes: "hello" }],
|
||
updateChatMetadata(patch) {
|
||
const base =
|
||
this.chatMetadata &&
|
||
typeof this.chatMetadata === "object" &&
|
||
!Array.isArray(this.chatMetadata)
|
||
? this.chatMetadata
|
||
: {};
|
||
this.chatMetadata = {
|
||
...base,
|
||
...(patch || {}),
|
||
};
|
||
},
|
||
saveMetadataDebounced() {},
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "late-empty-sync",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.synced, true);
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().loadState,
|
||
"empty-confirmed",
|
||
);
|
||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-metadata-placeholder",
|
||
chatMetadata: {
|
||
placeholder: "host-loading",
|
||
},
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "metadata-placeholder-not-ready",
|
||
});
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(
|
||
result.loadState,
|
||
"loading",
|
||
"无图谱数据时应进入 IndexedDB 探测等待态",
|
||
);
|
||
assert.equal(
|
||
result.reason,
|
||
"indexeddb-probe-pending",
|
||
"应继续等待 IndexedDB 探测结果",
|
||
);
|
||
assert.equal(live.writesBlocked, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-metadata-chatid-ready",
|
||
chatMetadata: {
|
||
chatId: "chat-metadata-chatid-ready",
|
||
},
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "metadata-chatid-ready",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceLiveState().writesBlocked,
|
||
true,
|
||
"无 IndexedDB 命中时应维持 loading 等待探测结果",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "",
|
||
globalChatId: "",
|
||
characterId: "char-1",
|
||
chatMetadata: undefined,
|
||
chat: [{ is_user: true, mes: "hello" }],
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "pending-chat-context",
|
||
});
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(live.loadState, "loading");
|
||
assert.equal(live.reason, "chat-id-missing");
|
||
assert.equal(live.writesBlocked, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-blocked",
|
||
chatMetadata: undefined,
|
||
});
|
||
const graph = createMeaningfulGraph("chat-blocked", "blocked");
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loading",
|
||
chatId: "chat-blocked",
|
||
reason: "chat-metadata-missing",
|
||
revision: 4,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
const result = harness.api.saveGraphToChat({
|
||
reason: "blocked-save",
|
||
markMutation: false,
|
||
});
|
||
assert.equal(result.saved, false);
|
||
assert.equal(result.queued, true);
|
||
assert.equal(result.blocked, false);
|
||
assert.equal(result.saveMode, "indexeddb-queued");
|
||
assert.equal(harness.runtimeContext.__chatContext.chatMetadata, undefined);
|
||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(harness.runtimeContext.__globalSaveCalls, 0);
|
||
|
||
const shadow = harness.api.readGraphShadowSnapshot("chat-blocked");
|
||
assert.equal(shadow, null, "IndexedDB 主路径不再依赖会话影子快照");
|
||
assert.equal(
|
||
harness.api.readRuntimeDebugSnapshot().graphPersistence
|
||
?.queuedPersistRevision,
|
||
0,
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-empty",
|
||
chatMetadata: undefined,
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(createEmptyGraph(), "chat-empty"),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loading",
|
||
chatId: "chat-empty",
|
||
reason: "chat-metadata-missing",
|
||
revision: 0,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
const result = harness.api.saveGraphToChat({
|
||
reason: "loading-empty-save",
|
||
markMutation: false,
|
||
});
|
||
assert.equal(result.blocked, false);
|
||
assert.equal(result.queued, false);
|
||
assert.equal(result.reason, "passive-empty-graph-skipped");
|
||
assert.equal(
|
||
harness.api.readGraphShadowSnapshot("chat-empty"),
|
||
null,
|
||
"空图不应污染影子快照",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-message",
|
||
chatMetadata: undefined,
|
||
});
|
||
harness.api.setCurrentGraph(createMeaningfulGraph("chat-message", "message"));
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loading",
|
||
chatId: "chat-message",
|
||
reason: "chat-metadata-missing",
|
||
revision: 2,
|
||
writesBlocked: true,
|
||
});
|
||
|
||
harness.api.onMessageReceived();
|
||
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata,
|
||
undefined,
|
||
"onMessageReceived 不应在 loading 期间写回 chat metadata",
|
||
);
|
||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(
|
||
harness.api.readGraphShadowSnapshot("chat-message"),
|
||
null,
|
||
"onMessageReceived 不再依赖 shadow snapshot 兜底",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-late-reconcile",
|
||
chatMetadata: undefined,
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(createEmptyGraph(), "chat-late-reconcile"),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "blocked",
|
||
chatId: "chat-late-reconcile",
|
||
reason: "chat-metadata-timeout",
|
||
revision: 2,
|
||
writesBlocked: true,
|
||
});
|
||
harness.api.setChatContext({
|
||
...harness.api.getChatContext(),
|
||
chatId: "chat-late-reconcile",
|
||
chatMetadata: {
|
||
integrity: "chat-late-reconcile-ready",
|
||
st_bme_graph: createMeaningfulGraph(
|
||
"chat-late-reconcile",
|
||
"late-official",
|
||
),
|
||
},
|
||
});
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-late-reconcile", "late-indexeddb"),
|
||
{
|
||
chatId: "chat-late-reconcile",
|
||
revision: 7,
|
||
},
|
||
),
|
||
);
|
||
|
||
harness.api.onMessageReceived();
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
assert.equal(live.loadState, "loaded");
|
||
assert.equal(live.writesBlocked, false);
|
||
assert.equal(live.storagePrimary, "indexeddb");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-late-indexeddb",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-sync-refresh",
|
||
chatMetadata: {
|
||
integrity: "chat-sync-refresh-ready",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-sync-refresh", "stale-runtime"),
|
||
"chat-sync-refresh",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-sync-refresh",
|
||
reason: "runtime-stale",
|
||
revision: 2,
|
||
lastPersistedRevision: 2,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-sync-refresh", "fresh-indexeddb"),
|
||
{
|
||
chatId: "chat-sync-refresh",
|
||
revision: 7,
|
||
},
|
||
),
|
||
);
|
||
|
||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||
await runtimeOptions.onSyncApplied({
|
||
chatId: "chat-sync-refresh",
|
||
action: "download",
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-fresh-indexeddb",
|
||
"download/merge 后应刷新当前运行时图谱",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-sync-refresh-merge",
|
||
chatMetadata: {
|
||
integrity: "chat-sync-refresh-merge-ready",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-sync-refresh-merge", "stale-runtime-merge"),
|
||
"chat-sync-refresh-merge",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-sync-refresh-merge",
|
||
reason: "runtime-stale",
|
||
revision: 3,
|
||
lastPersistedRevision: 3,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.setIndexedDbSnapshot(
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-sync-refresh-merge", "fresh-indexeddb-merge"),
|
||
{
|
||
chatId: "chat-sync-refresh-merge",
|
||
revision: 8,
|
||
},
|
||
),
|
||
);
|
||
|
||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||
await runtimeOptions.onSyncApplied({
|
||
chatId: "chat-sync-refresh-merge",
|
||
action: "merge",
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-fresh-indexeddb-merge",
|
||
"merge 后应刷新当前运行时图谱",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-sync-refresh-active",
|
||
chatMetadata: {
|
||
integrity: "chat-sync-refresh-active-ready",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-sync-refresh-active", "active-runtime"),
|
||
"chat-sync-refresh-active",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-sync-refresh-active",
|
||
reason: "runtime-active",
|
||
revision: 4,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||
await runtimeOptions.onSyncApplied({
|
||
chatId: "chat-sync-refresh-other",
|
||
action: "download",
|
||
});
|
||
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-active-runtime",
|
||
"active chat 与 sync payload chat 不一致时不应覆盖当前运行时图谱",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-panel-host",
|
||
globalChatId: "chat-panel-host",
|
||
chatMetadata: {
|
||
integrity: "chat-panel-integrity",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-panel-host", "runtime-host"),
|
||
"chat-panel-host",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-panel-host",
|
||
reason: "runtime-host-loaded",
|
||
revision: 6,
|
||
lastPersistedRevision: 6,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "panel-open-sync",
|
||
});
|
||
|
||
assert.equal(
|
||
result.synced,
|
||
false,
|
||
"hostChatId 与 integrity 只是同一聊天的不同身份时,不应误判为需要重新加载",
|
||
);
|
||
assert.equal(result.reason, "no-sync-needed");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-stale-cache",
|
||
globalChatId: "chat-stale-cache",
|
||
chatMetadata: {
|
||
integrity: "chat-stale-cache-integrity",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-stale-cache", "runtime-newer"),
|
||
"chat-stale-cache",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-stale-cache",
|
||
reason: "runtime-newer",
|
||
revision: 9,
|
||
lastPersistedRevision: 9,
|
||
queuedPersistRevision: 9,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.setIndexedDbSnapshotForChat(
|
||
"chat-stale-cache-integrity",
|
||
buildSnapshotFromGraph(
|
||
createMeaningfulGraph("chat-stale-cache", "indexeddb-older"),
|
||
{
|
||
chatId: "chat-stale-cache-integrity",
|
||
revision: 4,
|
||
},
|
||
),
|
||
);
|
||
|
||
const result = await harness.api.loadGraphFromIndexedDb(
|
||
"chat-stale-cache-integrity",
|
||
{
|
||
source: "sync-post-refresh:download",
|
||
allowOverride: true,
|
||
applyEmptyState: true,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.success, false);
|
||
assert.equal(result.loaded, false);
|
||
assert.equal(result.reason, "indexeddb-stale-runtime");
|
||
assert.equal(
|
||
result.staleDetail?.reason,
|
||
"runtime-revision-newer",
|
||
"同聊天较旧的 IndexedDB 快照应被识别为过期",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-runtime-newer",
|
||
"较旧的 IndexedDB 快照不得覆盖当前更近的运行时图谱",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceLiveState().loadState,
|
||
"loaded",
|
||
"拒绝旧快照后不应把当前图谱重新打回 loading",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-stale-cache-panel",
|
||
globalChatId: "chat-stale-cache-panel",
|
||
chatMetadata: {
|
||
integrity: "chat-stale-cache-panel-integrity",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-stale-cache-panel", "runtime-newer"),
|
||
"chat-stale-cache-panel",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-stale-cache-panel",
|
||
reason: "runtime-newer",
|
||
revision: 9,
|
||
lastPersistedRevision: 9,
|
||
queuedPersistRevision: 9,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "panel-open-sync",
|
||
});
|
||
|
||
assert.equal(
|
||
result.synced,
|
||
false,
|
||
"hostChatId 与 integrity 只是同一聊天的不同身份时,面板打开不应误判成要重新同步",
|
||
);
|
||
assert.equal(result.reason, "no-sync-needed");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-panel-open-healthy",
|
||
globalChatId: "chat-panel-open-healthy",
|
||
chatMetadata: {
|
||
integrity: "chat-panel-open-healthy-integrity",
|
||
},
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
graphLocalStorageMode: "auto",
|
||
};
|
||
harness.api.setLocalStoreCapabilitySnapshot({
|
||
checked: true,
|
||
opfsAvailable: true,
|
||
reason: "ok",
|
||
});
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-panel-open-healthy",
|
||
reason: "healthy",
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
pendingPersist: false,
|
||
indexedDbLastError: "",
|
||
resolvedLocalStore: "opfs:opfs-primary",
|
||
storagePrimary: "opfs",
|
||
storageMode: "opfs-primary",
|
||
});
|
||
|
||
const plan = harness.api.buildPanelOpenLocalStoreRefreshPlan();
|
||
|
||
assert.equal(
|
||
plan.shouldRefresh,
|
||
false,
|
||
"健康态的面板打开不应每次都强刷本地引擎绑定",
|
||
);
|
||
assert.equal(Array.isArray(plan.reasons), true);
|
||
assert.equal(plan.reasons.length, 0);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-panel-open-pending",
|
||
globalChatId: "chat-panel-open-pending",
|
||
chatMetadata: {
|
||
integrity: "chat-panel-open-pending-integrity",
|
||
},
|
||
});
|
||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||
graphLocalStorageMode: "auto",
|
||
};
|
||
harness.api.setLocalStoreCapabilitySnapshot({
|
||
checked: true,
|
||
opfsAvailable: true,
|
||
reason: "ok",
|
||
});
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "blocked",
|
||
chatId: "chat-panel-open-pending",
|
||
reason: "persist-queued",
|
||
dbReady: false,
|
||
writesBlocked: true,
|
||
pendingPersist: true,
|
||
indexedDbLastError: "opfs-write-failed",
|
||
resolvedLocalStore: "indexeddb:indexeddb",
|
||
storagePrimary: "indexeddb",
|
||
storageMode: "indexeddb",
|
||
});
|
||
|
||
const plan = harness.api.buildPanelOpenLocalStoreRefreshPlan();
|
||
|
||
assert.equal(plan.shouldRefresh, true);
|
||
assert.equal(plan.forceCapabilityRefresh, true);
|
||
assert.equal(plan.reopenCurrentDb, true);
|
||
assert.equal(plan.reasons.includes("pending-persist"), true);
|
||
assert.equal(plan.reasons.includes("resolved-store-mismatch"), true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-luker-panel-open",
|
||
globalChatId: "chat-luker-panel-open",
|
||
characterId: "char-luker-panel-open",
|
||
chatMetadata: {
|
||
integrity: "chat-luker-panel-open-integrity",
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "idle",
|
||
chatId: "chat-luker-panel-open",
|
||
reason: "cold-start",
|
||
revision: 0,
|
||
lastPersistedRevision: 0,
|
||
dbReady: false,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||
source: "panel-open-sync",
|
||
});
|
||
|
||
assert.equal(
|
||
result.reason,
|
||
"luker-chat-state-probe-pending",
|
||
"Luker 面板打开时应进入 chat-state probe,而不是抛出未定义变量异常",
|
||
);
|
||
assert.equal(result.attemptIndex, 0);
|
||
assert.equal(harness.api.getGraphPersistenceState().loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().primaryStorageTier,
|
||
"luker-chat-state",
|
||
);
|
||
}
|
||
|
||
{
|
||
const metadataGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-stale-metadata", "metadata-older"),
|
||
{
|
||
revision: 3,
|
||
integrity: "chat-stale-metadata-integrity",
|
||
chatId: "chat-stale-metadata",
|
||
reason: "metadata-older",
|
||
},
|
||
);
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-stale-metadata",
|
||
globalChatId: "chat-stale-metadata",
|
||
chatMetadata: {
|
||
integrity: "chat-stale-metadata-integrity",
|
||
st_bme_graph: metadataGraph,
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
normalizeGraphRuntimeState(
|
||
createMeaningfulGraph("chat-stale-metadata", "runtime-newer"),
|
||
"chat-stale-metadata",
|
||
),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-stale-metadata",
|
||
reason: "runtime-newer",
|
||
revision: 8,
|
||
lastPersistedRevision: 8,
|
||
dbReady: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "stale-metadata-runtime-guard",
|
||
});
|
||
|
||
assert.equal(result.reason, "metadata-compat-stale-runtime");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-runtime-newer",
|
||
"较旧的 metadata 兼容图不得把当前运行时图谱盖回去",
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-shadow",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-shadow",
|
||
createMeaningfulGraph("chat-shadow", "shadow"),
|
||
{ revision: 7, reason: "manual-shadow" },
|
||
);
|
||
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-shadow",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "shadow-test",
|
||
});
|
||
|
||
assert.equal(result.loadState, "shadow-restored");
|
||
assert.equal(
|
||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-shadow",
|
||
);
|
||
assert.equal(
|
||
reader.api.getGraphPersistenceLiveState().shadowSnapshotUsed,
|
||
true,
|
||
);
|
||
assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, false);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-official",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-official",
|
||
createMeaningfulGraph("chat-official", "shadow-stale"),
|
||
{ revision: 3, reason: "stale-shadow" },
|
||
);
|
||
|
||
const officialGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-official", "official"),
|
||
{ revision: 6, integrity: "official-integrity" },
|
||
);
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-official",
|
||
chatMetadata: {
|
||
integrity: "official-integrity",
|
||
st_bme_graph: officialGraph,
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "official-load",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-official",
|
||
);
|
||
assert.equal(
|
||
reader.api.readGraphShadowSnapshot("chat-official")?.reason,
|
||
"stale-shadow",
|
||
"metadata 兼容加载时保留影子快照仅作为兼容数据,不参与主链路",
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-shadow-newer",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
const shadowGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-shadow-newer", "shadow-newer"),
|
||
{
|
||
revision: 9,
|
||
integrity: "integrity-shadow-mismatch",
|
||
chatId: "chat-shadow-newer",
|
||
reason: "pagehide-refresh",
|
||
},
|
||
);
|
||
writer.api.writeGraphShadowSnapshot("chat-shadow-newer", shadowGraph, {
|
||
revision: 9,
|
||
reason: "pagehide-refresh",
|
||
integrity: "integrity-shadow-mismatch",
|
||
debugReason: "pagehide-refresh",
|
||
});
|
||
|
||
const officialGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-shadow-newer", "official-older"),
|
||
{ revision: 3, integrity: "integrity-official-older" },
|
||
);
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-shadow-newer",
|
||
chatMetadata: {
|
||
integrity: "integrity-official-older",
|
||
st_bme_graph: officialGraph,
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "official-older-than-shadow",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
result.reason,
|
||
"official-older-than-shadow:metadata-compat-provisional",
|
||
);
|
||
assert.equal(
|
||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-official-older",
|
||
);
|
||
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(
|
||
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes?.[0]
|
||
?.fields?.title,
|
||
"事件-official-older",
|
||
);
|
||
assert.equal(
|
||
reader.api.readGraphShadowSnapshot("chat-shadow-newer")?.reason,
|
||
"pagehide-refresh",
|
||
"metadata 兼容加载后影子快照可保留,但不作为主链路恢复来源",
|
||
);
|
||
const live = reader.api.getGraphPersistenceLiveState();
|
||
assert.equal(live.shadowSnapshotRevision, 9);
|
||
assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch");
|
||
const compareDecision = shouldPreferShadowSnapshotOverOfficial(
|
||
officialGraph,
|
||
reader.api.readGraphShadowSnapshot("chat-shadow-newer"),
|
||
);
|
||
assert.equal(compareDecision.resultCode, "shadow.reject.integrity-mismatch");
|
||
}
|
||
|
||
{
|
||
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||
stampPersistedGraph(createMeaningfulGraph("chat-self-mismatch"), {
|
||
revision: 0,
|
||
chatId: "",
|
||
integrity: "",
|
||
}),
|
||
{
|
||
chatId: "chat-self-mismatch",
|
||
persistedChatId: "chat-other",
|
||
revision: 5,
|
||
integrity: "",
|
||
},
|
||
);
|
||
assert.equal(decision.prefer, false);
|
||
assert.equal(decision.reason, "shadow-self-chat-mismatch");
|
||
assert.equal(decision.resultCode, "shadow.reject.self-chat-mismatch");
|
||
}
|
||
|
||
{
|
||
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||
stampPersistedGraph(createMeaningfulGraph("chat-official-missing"), {
|
||
revision: 0,
|
||
chatId: "",
|
||
integrity: "",
|
||
}),
|
||
{
|
||
chatId: "chat-official-missing",
|
||
persistedChatId: "chat-official-missing",
|
||
revision: 4,
|
||
integrity: "",
|
||
},
|
||
);
|
||
assert.equal(decision.prefer, false);
|
||
assert.equal(decision.reason, "shadow-persisted-chat-without-official-chat");
|
||
assert.equal(
|
||
decision.resultCode,
|
||
"shadow.reject.persisted-chat-without-official-chat",
|
||
);
|
||
}
|
||
|
||
{
|
||
const decision = shouldPreferShadowSnapshotOverOfficial(
|
||
stampPersistedGraph(
|
||
createMeaningfulGraph("chat-official-integrity-missing"),
|
||
{
|
||
revision: 0,
|
||
chatId: "chat-official-integrity-missing",
|
||
integrity: "",
|
||
},
|
||
),
|
||
{
|
||
chatId: "chat-official-integrity-missing",
|
||
persistedChatId: "chat-official-integrity-missing",
|
||
revision: 4,
|
||
integrity: "shadow-only-integrity",
|
||
},
|
||
);
|
||
assert.equal(decision.prefer, false);
|
||
assert.equal(decision.reason, "shadow-integrity-without-official-integrity");
|
||
assert.equal(
|
||
decision.resultCode,
|
||
"shadow.reject.integrity-without-official-integrity",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-empty-confirmed",
|
||
chatMetadata: {
|
||
integrity: "meta-ready-empty",
|
||
},
|
||
});
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "ready-empty",
|
||
});
|
||
const live = harness.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(result.reason, "indexeddb-probe-pending");
|
||
assert.equal(live.writesBlocked, true);
|
||
assert.equal(harness.api.getCurrentGraph(), null);
|
||
assert.equal(
|
||
harness.api.readRuntimeDebugSnapshot().graphPersistence?.loadState,
|
||
"loading",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-empty-confirmed-passive",
|
||
chatMetadata: {
|
||
integrity: "meta-ready-empty-passive",
|
||
},
|
||
});
|
||
harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "ready-empty-passive",
|
||
});
|
||
|
||
harness.api.onMessageReceived();
|
||
|
||
assert.equal(
|
||
harness.runtimeContext.__contextImmediateSaveCalls,
|
||
0,
|
||
"空聊天的被动同步不应触发立即保存",
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__contextSaveCalls,
|
||
0,
|
||
"空聊天的被动同步不应触发防抖保存",
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph,
|
||
undefined,
|
||
"loading 状态下不能把空图被动写回 metadata",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-manager-unavailable-fallback",
|
||
globalChatId: "chat-manager-unavailable-fallback",
|
||
chatMetadata: {
|
||
integrity: "meta-manager-unavailable-fallback",
|
||
},
|
||
});
|
||
harness.runtimeContext.BmeChatManager = null;
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: harness.api.GRAPH_LOAD_RETRY_DELAYS_MS.length,
|
||
source: "manager-unavailable-fallback",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().loadState,
|
||
"blocked",
|
||
"IndexedDB manager 不可用时,重试耗尽后不应永久停留在 loading",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().reason,
|
||
"indexeddb-manager-unavailable",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-read-failed-fallback",
|
||
globalChatId: "chat-indexeddb-read-failed-fallback",
|
||
chatMetadata: {
|
||
integrity: "meta-indexeddb-read-failed-fallback",
|
||
},
|
||
});
|
||
harness.runtimeContext.__indexedDbExportSnapshotShouldThrow = true;
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: harness.api.GRAPH_LOAD_RETRY_DELAYS_MS.length,
|
||
source: "indexeddb-read-failed-fallback",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().loadState,
|
||
"blocked",
|
||
"IndexedDB 读取失败时,重试耗尽后不应永久停留在 loading",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().reason,
|
||
"indexeddb-read-failed",
|
||
);
|
||
}
|
||
|
||
{
|
||
const commitMarker = buildGraphCommitMarker(
|
||
createMeaningfulGraph("chat-indexeddb-empty-mismatch-fallback", "marker"),
|
||
{
|
||
revision: 4,
|
||
storageTier: "indexeddb",
|
||
accepted: true,
|
||
reason: "test-empty-mismatch",
|
||
chatId: "chat-indexeddb-empty-mismatch-fallback",
|
||
integrity: "meta-indexeddb-empty-mismatch-fallback",
|
||
},
|
||
);
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-empty-mismatch-fallback",
|
||
globalChatId: "chat-indexeddb-empty-mismatch-fallback",
|
||
chatMetadata: {
|
||
integrity: "meta-indexeddb-empty-mismatch-fallback",
|
||
[GRAPH_COMMIT_MARKER_KEY]: commitMarker,
|
||
},
|
||
});
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: harness.api.GRAPH_LOAD_RETRY_DELAYS_MS.length,
|
||
source: "indexeddb-empty-mismatch-fallback",
|
||
});
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().loadState,
|
||
"empty-confirmed",
|
||
"当 accepted commit marker 已成孤儿且本地不存在可恢复图谱源时,应自动降级为 empty-confirmed",
|
||
);
|
||
assert.match(
|
||
String(harness.api.getGraphPersistenceState().reason || ""),
|
||
/orphan-accepted-marker/,
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.[GRAPH_COMMIT_MARKER_KEY],
|
||
null,
|
||
);
|
||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 1);
|
||
assert.equal(harness.api.getGraphPersistenceState().lastAcceptedRevision, 0);
|
||
assert.equal(harness.api.getGraphPersistenceState().commitMarker, null);
|
||
}
|
||
|
||
{
|
||
const commitMarker = buildGraphCommitMarker(
|
||
createMeaningfulGraph("chat-indexeddb-empty-chat-state-rescue", "marker"),
|
||
{
|
||
revision: 8,
|
||
storageTier: "indexeddb",
|
||
accepted: true,
|
||
reason: "test-chat-state-rescue",
|
||
chatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
integrity: "meta-indexeddb-empty-chat-state-rescue",
|
||
},
|
||
);
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
globalChatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
chatMetadata: {
|
||
integrity: "meta-indexeddb-empty-chat-state-rescue",
|
||
[GRAPH_COMMIT_MARKER_KEY]: commitMarker,
|
||
},
|
||
});
|
||
const sidecarGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-indexeddb-empty-chat-state-rescue", "sidecar"),
|
||
{
|
||
revision: 8,
|
||
integrity: "meta-indexeddb-empty-chat-state-rescue",
|
||
chatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
reason: "sidecar-rescue-seed",
|
||
},
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
buildGraphChatStateSnapshot(sidecarGraph, {
|
||
revision: 8,
|
||
storageTier: "chat-state",
|
||
accepted: true,
|
||
reason: "sidecar-rescue-seed",
|
||
chatId: "chat-indexeddb-empty-chat-state-rescue",
|
||
integrity: "meta-indexeddb-empty-chat-state-rescue",
|
||
lastProcessedAssistantFloor: 6,
|
||
extractionCount: 3,
|
||
}),
|
||
);
|
||
|
||
const result = await harness.api.loadGraphFromIndexedDb(
|
||
"chat-indexeddb-empty-chat-state-rescue",
|
||
{
|
||
source: "indexeddb-empty-chat-state-rescue",
|
||
attemptIndex: 0,
|
||
allowOverride: true,
|
||
applyEmptyState: true,
|
||
},
|
||
);
|
||
|
||
assert.equal(result.loaded, true);
|
||
assert.equal(result.loadState, "loaded");
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||
"事件-sidecar",
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.[GRAPH_COMMIT_MARKER_KEY]
|
||
?.revision,
|
||
8,
|
||
);
|
||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().persistMismatchReason,
|
||
"persist-mismatch:indexeddb-behind-commit-marker",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-create-first-graph",
|
||
chatMetadata: {
|
||
integrity: "integrity-before-first-save",
|
||
},
|
||
});
|
||
harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "ready-for-first-save",
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
createMeaningfulGraph("chat-create-first-graph", "first-save"),
|
||
);
|
||
|
||
const result = harness.api.saveGraphToChat({
|
||
reason: "first-meaningful-graph",
|
||
});
|
||
|
||
assert.equal(result.saved, false);
|
||
assert.equal(result.queued, true);
|
||
assert.equal(result.saveMode, "indexeddb-queued");
|
||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.integrity ===
|
||
"integrity-before-first-save",
|
||
true,
|
||
"插件保存图谱时不能改写宿主 metadata.integrity",
|
||
);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph,
|
||
undefined,
|
||
);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision) > 0,
|
||
true,
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-promote",
|
||
chatMetadata: undefined,
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-promote",
|
||
createMeaningfulGraph("chat-promote", "promote"),
|
||
{ revision: 9, reason: "pre-refresh" },
|
||
);
|
||
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-promote",
|
||
chatMetadata: {
|
||
integrity: "meta-ready-promote",
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "promote-when-metadata-ready",
|
||
});
|
||
const live = reader.api.getGraphPersistenceLiveState();
|
||
|
||
assert.equal(result.loadState, "shadow-restored");
|
||
assert.equal(
|
||
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes
|
||
?.length,
|
||
undefined,
|
||
);
|
||
assert.equal(
|
||
reader.runtimeContext.__chatContext.chatMetadata?.integrity,
|
||
"meta-ready-promote",
|
||
);
|
||
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(reader.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(live.lastPersistedRevision, 9);
|
||
assert.equal(live.pendingPersist, true);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-decouple",
|
||
chatMetadata: {
|
||
integrity: "meta-decouple",
|
||
},
|
||
});
|
||
const runtimeGraph = createMeaningfulGraph("chat-decouple", "runtime");
|
||
harness.api.setCurrentGraph(runtimeGraph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-decouple",
|
||
revision: 3,
|
||
lastPersistedRevision: 0,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.saveGraphToChat({
|
||
reason: "decouple-metadata-runtime",
|
||
markMutation: false,
|
||
persistMetadata: true,
|
||
});
|
||
|
||
assert.equal(result.saved, true);
|
||
const persistedGraph =
|
||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph;
|
||
assert.notEqual(
|
||
persistedGraph,
|
||
harness.api.getCurrentGraph(),
|
||
"写入 metadata 时必须使用独立 graph 快照",
|
||
);
|
||
|
||
persistedGraph.nodes[0].fields.title = "metadata-mutated";
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().nodes[0].fields.title,
|
||
"事件-runtime",
|
||
"metadata 修改不能反向污染运行时 graph",
|
||
);
|
||
|
||
harness.api.getCurrentGraph().nodes[0].fields.title = "runtime-mutated";
|
||
assert.equal(
|
||
persistedGraph.nodes[0].fields.title,
|
||
"metadata-mutated",
|
||
"运行时修改不能反向污染已保存 metadata",
|
||
);
|
||
}
|
||
|
||
{
|
||
const officialGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-load-official", "official"),
|
||
{
|
||
revision: 4,
|
||
integrity: "meta-load-official",
|
||
chatId: "chat-load-official",
|
||
reason: "official-save",
|
||
},
|
||
);
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-load-official",
|
||
chatMetadata: {
|
||
integrity: "meta-load-official",
|
||
st_bme_graph: officialGraph,
|
||
},
|
||
});
|
||
|
||
const result = harness.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "load-official-decoupled",
|
||
});
|
||
|
||
assert.equal(result.loadState, "loading");
|
||
const runtimeGraph = harness.api.getCurrentGraph();
|
||
const persistedGraph =
|
||
harness.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||
assert.notEqual(
|
||
runtimeGraph,
|
||
persistedGraph,
|
||
"从 official metadata 恢复到运行时必须使用独立对象",
|
||
);
|
||
|
||
runtimeGraph.nodes[0].fields.title = "runtime-after-load";
|
||
assert.equal(
|
||
persistedGraph.nodes[0].fields.title,
|
||
"事件-official",
|
||
"official metadata 不应被运行时修改污染",
|
||
);
|
||
}
|
||
|
||
{
|
||
const sharedSession = new Map();
|
||
const writer = await createGraphPersistenceHarness({
|
||
chatId: "chat-load-shadow",
|
||
chatMetadata: {
|
||
integrity: "meta-load-shadow",
|
||
st_bme_graph: stampPersistedGraph(
|
||
createMeaningfulGraph("chat-load-shadow", "official-older"),
|
||
{
|
||
revision: 2,
|
||
integrity: "meta-load-shadow",
|
||
chatId: "chat-load-shadow",
|
||
reason: "official-older",
|
||
},
|
||
),
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
writer.api.writeGraphShadowSnapshot(
|
||
"chat-load-shadow",
|
||
createMeaningfulGraph("chat-load-shadow", "shadow"),
|
||
{
|
||
revision: 5,
|
||
reason: "shadow-newer",
|
||
},
|
||
);
|
||
|
||
const reader = await createGraphPersistenceHarness({
|
||
chatId: "chat-load-shadow",
|
||
chatMetadata: {
|
||
integrity: "meta-load-shadow",
|
||
st_bme_graph: stampPersistedGraph(
|
||
createMeaningfulGraph("chat-load-shadow", "official-older"),
|
||
{
|
||
revision: 2,
|
||
integrity: "meta-load-shadow",
|
||
chatId: "chat-load-shadow",
|
||
reason: "official-older",
|
||
},
|
||
),
|
||
},
|
||
sessionStore: sharedSession,
|
||
});
|
||
|
||
const result = reader.api.loadGraphFromChat({
|
||
attemptIndex: 0,
|
||
source: "load-shadow-decoupled",
|
||
});
|
||
|
||
assert.equal(result.loadState, "shadow-restored");
|
||
const runtimeGraph = reader.api.getCurrentGraph();
|
||
const persistedGraph =
|
||
reader.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||
assert.notEqual(
|
||
runtimeGraph,
|
||
persistedGraph,
|
||
"从 shadow snapshot 提升后,运行时与 metadata 也必须解耦",
|
||
);
|
||
|
||
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",
|
||
"metadata 兼容加载后的运行时修改不能污染已保存 metadata",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-two-saves",
|
||
chatMetadata: {
|
||
integrity: "meta-two-saves",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(createMeaningfulGraph("chat-two-saves", "first"));
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-two-saves",
|
||
revision: 1,
|
||
lastPersistedRevision: 0,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const firstSave = harness.api.saveGraphToChat({
|
||
reason: "first-save",
|
||
markMutation: false,
|
||
persistMetadata: true,
|
||
});
|
||
assert.equal(firstSave.saved, true);
|
||
const firstPersistedGraph =
|
||
harness.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||
|
||
harness.api.getCurrentGraph().nodes[0].fields.title = "runtime-between-saves";
|
||
assert.equal(
|
||
firstPersistedGraph.nodes[0].fields.title,
|
||
"事件-first",
|
||
"第一次保存后的 metadata 不应被后续运行时修改污染",
|
||
);
|
||
|
||
harness.api.setGraphPersistenceState({ revision: 2 });
|
||
const secondSave = harness.api.saveGraphToChat({
|
||
reason: "second-save",
|
||
markMutation: false,
|
||
persistMetadata: true,
|
||
});
|
||
assert.equal(secondSave.saved, true);
|
||
const secondPersistedGraph =
|
||
harness.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||
|
||
assert.notEqual(
|
||
secondPersistedGraph,
|
||
firstPersistedGraph,
|
||
"第二次保存应生成新的 metadata graph 快照",
|
||
);
|
||
assert.equal(
|
||
secondPersistedGraph.nodes[0].fields.title,
|
||
"runtime-between-saves",
|
||
"第二次保存应反映第二轮运行时修改",
|
||
);
|
||
harness.api.getCurrentGraph().nodes[0].fields.title =
|
||
"runtime-after-second-save";
|
||
assert.equal(
|
||
firstPersistedGraph.nodes[0].fields.title,
|
||
"事件-first",
|
||
"第二轮运行时修改仍不能污染第一次已保存 metadata",
|
||
);
|
||
assert.equal(
|
||
secondPersistedGraph.nodes[0].fields.title,
|
||
"runtime-between-saves",
|
||
"第二次已保存 metadata 也不能被后续运行时修改污染",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-idb-ancillary-warning",
|
||
globalChatId: "chat-idb-ancillary-warning",
|
||
chatMetadata: {
|
||
integrity: "meta-idb-ancillary-warning",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(
|
||
createMeaningfulGraph("chat-idb-ancillary-warning", "ancillary-warning"),
|
||
);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-idb-ancillary-warning",
|
||
revision: 7,
|
||
lastPersistedRevision: 0,
|
||
writesBlocked: false,
|
||
});
|
||
harness.runtimeContext.__scheduleUploadShouldThrow = true;
|
||
|
||
const result = await harness.api.saveGraphToIndexedDb(
|
||
"chat-idb-ancillary-warning",
|
||
harness.api.getCurrentGraph(),
|
||
{
|
||
revision: 7,
|
||
reason: "ancillary-warning-save",
|
||
},
|
||
);
|
||
|
||
assert.equal(result.saved, true);
|
||
assert.match(String(result.warning || ""), /schedule-upload-failed/);
|
||
assert.equal(
|
||
harness.api.getIndexedDbSnapshot().meta.revision,
|
||
7,
|
||
"附属步骤失败时,IndexedDB 主写仍应视为成功",
|
||
);
|
||
const persistDeltaDiagnostics = harness.api.getGraphPersistenceState().persistDelta;
|
||
assert.equal(Boolean(persistDeltaDiagnostics), true);
|
||
assert.equal(persistDeltaDiagnostics.status, "committed");
|
||
assert.equal(persistDeltaDiagnostics.path, "js");
|
||
assert.equal(persistDeltaDiagnostics.requestedNative, false);
|
||
assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.buildMs)), true);
|
||
assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.prepareMs)), true);
|
||
assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.lookupMs)), true);
|
||
assert.equal(Number.isFinite(Number(persistDeltaDiagnostics.jsDiffMs)), true);
|
||
assert.equal(
|
||
Number(persistDeltaDiagnostics.serializationCacheHits || 0) +
|
||
Number(persistDeltaDiagnostics.serializationCacheMisses || 0) >
|
||
0,
|
||
true,
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-pending-persist-retry",
|
||
globalChatId: "chat-pending-persist-retry",
|
||
chatMetadata: {
|
||
integrity: "meta-pending-persist-retry",
|
||
},
|
||
chat: [
|
||
{ is_user: true, mes: "用户发言" },
|
||
{ is_user: false, mes: "助手回复" },
|
||
],
|
||
});
|
||
const graph = createMeaningfulGraph(
|
||
"chat-pending-persist-retry",
|
||
"pending-persist-retry",
|
||
);
|
||
graph.historyState.lastProcessedAssistantFloor = -1;
|
||
graph.lastProcessedSeq = -1;
|
||
graph.historyState.lastBatchStatus = {
|
||
processedRange: [1, 1],
|
||
completed: true,
|
||
stages: {
|
||
core: { outcome: "success" },
|
||
finalize: { outcome: "success" },
|
||
},
|
||
persistence: {
|
||
outcome: "queued",
|
||
accepted: false,
|
||
storageTier: "none",
|
||
reason: "extraction-batch-complete:pending",
|
||
revision: 7,
|
||
saveMode: "immediate",
|
||
saved: false,
|
||
queued: true,
|
||
blocked: true,
|
||
},
|
||
historyAdvanceAllowed: false,
|
||
historyAdvanced: false,
|
||
};
|
||
const committedGraph = structuredClone(graph);
|
||
committedGraph.historyState.lastProcessedAssistantFloor = 1;
|
||
committedGraph.lastProcessedSeq = 1;
|
||
committedGraph.batchJournal = [
|
||
{
|
||
id: "journal-queued-1",
|
||
processedRange: [1, 1],
|
||
createdAt: Date.now(),
|
||
},
|
||
];
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-pending-persist-retry",
|
||
revision: 7,
|
||
lastPersistedRevision: 0,
|
||
queuedPersistRevision: 7,
|
||
queuedPersistChatId: "chat-pending-persist-retry",
|
||
queuedPersistMode: "immediate",
|
||
pendingPersist: true,
|
||
writesBlocked: false,
|
||
});
|
||
harness.api.writeGraphShadowSnapshot(
|
||
"chat-pending-persist-retry",
|
||
committedGraph,
|
||
{
|
||
revision: 7,
|
||
reason: "queued-persist-authoritative",
|
||
},
|
||
);
|
||
harness.runtimeContext.__markSyncDirtyShouldThrow = true;
|
||
|
||
const result = await harness.api.retryPendingGraphPersist({
|
||
reason: "queued-persist-retry-test",
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().pendingPersist,
|
||
false,
|
||
"pendingPersist 在补存成功后应被清除",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.lastProcessedAssistantFloor,
|
||
1,
|
||
"补存成功后应推进 lastProcessedAssistantFloor",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.lastBatchStatus.historyAdvanceAllowed,
|
||
true,
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().historyState.lastBatchStatus.persistence.outcome,
|
||
"saved",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().batchJournal?.length,
|
||
1,
|
||
"pending persist retry 应把 authoritative batch journal 回填到 runtime graph",
|
||
);
|
||
assert.equal(
|
||
harness.api.getCurrentGraph().batchJournal?.[0]?.id,
|
||
"journal-queued-1",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-b",
|
||
globalChatId: "chat-b",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-b",
|
||
},
|
||
});
|
||
harness.api.setCurrentGraph(createMeaningfulGraph("chat-a", "queued"));
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-a",
|
||
revision: 6,
|
||
lastPersistedRevision: 4,
|
||
queuedPersistRevision: 6,
|
||
queuedPersistChatId: "chat-a",
|
||
queuedPersistMode: "immediate",
|
||
pendingPersist: true,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
const result = harness.api.maybeFlushQueuedGraphPersist("cross-chat-flush");
|
||
|
||
assert.equal(result.saved, false);
|
||
assert.equal(result.blocked, true);
|
||
assert.equal(result.reason, "queued-chat-mismatch");
|
||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0);
|
||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph,
|
||
undefined,
|
||
"跨 chat 的 queued persist 不得 flush 到当前 metadata",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceLiveState().queuedPersistChatId,
|
||
"chat-a",
|
||
"发生 chat mismatch 时应保留原始 queued chat 绑定",
|
||
);
|
||
}
|
||
|
||
// === Fix 2c: assertRecoveryChatStillActive 跨 chat 守卫 ===
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-recovery-a",
|
||
globalChatId: "chat-recovery-a",
|
||
chatMetadata: {
|
||
integrity: "meta-recovery-a",
|
||
},
|
||
});
|
||
|
||
// 同一 chat 不应抛出
|
||
harness.api.assertRecoveryChatStillActive("chat-recovery-a", "test-same");
|
||
|
||
// 切换到 chat-b
|
||
harness.runtimeContext.__globalChatId = "chat-recovery-b";
|
||
harness.runtimeContext.__chatContext.chatId = "chat-recovery-b";
|
||
|
||
let abortCaught = false;
|
||
try {
|
||
harness.api.assertRecoveryChatStillActive("chat-recovery-a", "test-switch");
|
||
} catch (e) {
|
||
abortCaught = harness.api.isAbortError(e);
|
||
}
|
||
assert.equal(
|
||
abortCaught,
|
||
true,
|
||
"chat 切换后 assertRecoveryChatStillActive 应抛出 AbortError",
|
||
);
|
||
|
||
// 空 expectedChatId 不应抛出
|
||
harness.api.assertRecoveryChatStillActive("", "test-empty");
|
||
harness.api.assertRecoveryChatStillActive(undefined, "test-undefined");
|
||
}
|
||
|
||
// === Fix 2e: resolveDirtyFloorFromMutationMeta 候选过滤 ===
|
||
// 此测试需要 resolveDirtyFloorFromMutationMeta 与 getAssistantTurns,
|
||
// 它们均在 persistencePrelude 范围内,通过 vm 上下文执行。
|
||
// 这里使用间接方式验证:构造一个只有晚期 assistant 的 chat,
|
||
// 然后检查 inspectHistoryMutation 不会对早期 floor 误判。
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-dirty-floor",
|
||
globalChatId: "chat-dirty-floor",
|
||
chatMetadata: {
|
||
integrity: "meta-dirty-floor",
|
||
},
|
||
chat: [
|
||
// index 0: user
|
||
{ is_user: true, mes: "hello" },
|
||
// index 1: user (no assistant before index 4)
|
||
{ is_user: true, mes: "second" },
|
||
// index 2: user
|
||
{ is_user: true, mes: "third" },
|
||
// index 3: user
|
||
{ is_user: true, mes: "fourth" },
|
||
// index 4: first assistant
|
||
{ is_user: false, mes: "first reply" },
|
||
],
|
||
});
|
||
|
||
const graph = createMeaningfulGraph("chat-dirty-floor", "dirty-floor");
|
||
graph.historyState.lastProcessedAssistantFloor = 4;
|
||
graph.historyState.extractionCount = 1;
|
||
harness.api.setCurrentGraph(graph);
|
||
harness.api.setGraphPersistenceState({
|
||
loadState: "loaded",
|
||
chatId: "chat-dirty-floor",
|
||
revision: 2,
|
||
writesBlocked: false,
|
||
});
|
||
|
||
// 模拟:meta 指向 floor=1(早于最小可提取 floor=4)的删除事件
|
||
// 使用间接方式:graph 的 lastProcessedAssistantFloor=4,
|
||
// 如果 resolveDirtyFloorFromMutationMeta 正确过滤了 floor<4 的候选,
|
||
// 那么 inspectHistoryMutation 不会标记为 dirty(因为没有有效候选)。
|
||
// 注意:这里不直接测试内部函数,而是验证整体行为。
|
||
const graph2 = harness.api.getCurrentGraph();
|
||
assert.ok(graph2, "graph 应存在");
|
||
assert.equal(
|
||
graph2.historyState.lastProcessedAssistantFloor,
|
||
4,
|
||
"lastProcessedAssistantFloor 应为 4",
|
||
);
|
||
}
|
||
|
||
{
|
||
const metadataGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-indexeddb-priority", "metadata"),
|
||
{
|
||
revision: 3,
|
||
integrity: "meta-indexeddb-priority",
|
||
chatId: "chat-indexeddb-priority",
|
||
reason: "metadata-seed",
|
||
},
|
||
);
|
||
const indexedDbGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-indexeddb-priority", "indexeddb"),
|
||
{
|
||
revision: 9,
|
||
integrity: "idxdb-indexeddb-priority",
|
||
chatId: "chat-indexeddb-priority",
|
||
reason: "indexeddb-seed",
|
||
},
|
||
);
|
||
const indexedDbSnapshot = buildSnapshotFromGraph(indexedDbGraph, {
|
||
chatId: "chat-indexeddb-priority",
|
||
revision: 9,
|
||
});
|
||
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-indexeddb-priority",
|
||
globalChatId: "chat-indexeddb-priority",
|
||
chatMetadata: {
|
||
integrity: "meta-indexeddb-priority",
|
||
[GRAPH_METADATA_KEY]: metadataGraph,
|
||
},
|
||
indexedDbSnapshot,
|
||
});
|
||
|
||
harness.api.loadGraphFromChat({ source: "indexeddb-priority" });
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-indexeddb");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().storagePrimary,
|
||
"indexeddb",
|
||
);
|
||
}
|
||
|
||
{
|
||
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"),
|
||
{
|
||
revision: 6,
|
||
integrity: "meta-legacy-migration",
|
||
chatId: "chat-legacy-migration",
|
||
reason: "legacy-seed",
|
||
},
|
||
);
|
||
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-legacy-migration",
|
||
globalChatId: "chat-legacy-migration",
|
||
chatMetadata: {
|
||
integrity: "meta-legacy-migration",
|
||
[GRAPH_METADATA_KEY]: legacyGraph,
|
||
},
|
||
indexedDbSnapshot: {
|
||
meta: {
|
||
chatId: "chat-legacy-migration",
|
||
revision: 0,
|
||
migrationCompletedAt: 0,
|
||
},
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
state: {
|
||
lastProcessedFloor: -1,
|
||
extractionCount: 0,
|
||
},
|
||
},
|
||
});
|
||
|
||
harness.api.loadGraphFromChat({ source: "legacy-migration-check" });
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
||
assert.ok(harness.runtimeContext.__syncNowCalls.length >= 1);
|
||
assert.equal(
|
||
harness.runtimeContext.__syncNowCalls[0].options.reason,
|
||
"post-migration",
|
||
);
|
||
assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-legacy");
|
||
assert.equal(
|
||
harness.api.getIndexedDbSnapshot().meta.migrationSource,
|
||
"chat_metadata",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-state-save",
|
||
globalChatId: "chat-state-save",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-state-save",
|
||
},
|
||
indexedDbSnapshot: {
|
||
meta: {
|
||
chatId: "chat-state-save",
|
||
revision: 0,
|
||
},
|
||
nodes: [],
|
||
edges: [],
|
||
tombstones: [],
|
||
state: {
|
||
lastProcessedFloor: -1,
|
||
extractionCount: 0,
|
||
},
|
||
},
|
||
});
|
||
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-state-save", "sidecar"),
|
||
{
|
||
revision: 7,
|
||
integrity: "meta-chat-state-save",
|
||
chatId: "chat-state-save",
|
||
reason: "chat-state-seed",
|
||
},
|
||
);
|
||
|
||
const result = await harness.runtimeContext.persistGraphToHostChatState(
|
||
harness.runtimeContext.__chatContext,
|
||
{
|
||
graph,
|
||
revision: 7,
|
||
reason: "chat-state-direct-save",
|
||
storageTier: "chat-state",
|
||
accepted: true,
|
||
lastProcessedAssistantFloor: 6,
|
||
extractionCount: 3,
|
||
mode: "primary",
|
||
},
|
||
);
|
||
|
||
assert.equal(result.saved, true);
|
||
const stored = await harness.runtimeContext.__chatContext.getChatState(
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
);
|
||
assert.equal(stored?.revision, 7);
|
||
assert.equal(stored?.commitMarker?.storageTier, "chat-state");
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().dualWriteLastResult?.target,
|
||
"chat-state",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-state-read",
|
||
globalChatId: "chat-state-read",
|
||
chatMetadata: {
|
||
integrity: "meta-chat-state-read",
|
||
},
|
||
});
|
||
|
||
const sidecarGraph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-state-read", "sidecar-read"),
|
||
{
|
||
revision: 9,
|
||
integrity: "meta-chat-state-read",
|
||
chatId: "chat-state-read",
|
||
reason: "chat-state-read-seed",
|
||
},
|
||
);
|
||
harness.runtimeContext.__chatContext.__chatStateStore.set(
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
buildGraphChatStateSnapshot(sidecarGraph, {
|
||
revision: 9,
|
||
storageTier: "chat-state",
|
||
accepted: true,
|
||
reason: "chat-state-read-seed",
|
||
chatId: "chat-state-read",
|
||
integrity: "meta-chat-state-read",
|
||
lastProcessedAssistantFloor: 6,
|
||
extractionCount: 3,
|
||
}),
|
||
);
|
||
|
||
const result = await harness.runtimeContext.readGraphChatStateSnapshot(
|
||
harness.runtimeContext.__chatContext,
|
||
{
|
||
namespace: GRAPH_CHAT_STATE_NAMESPACE,
|
||
},
|
||
);
|
||
|
||
assert.equal(
|
||
harness.runtimeContext.canUseGraphChatState(
|
||
harness.runtimeContext.__chatContext,
|
||
),
|
||
true,
|
||
);
|
||
assert.equal(result?.revision, 9);
|
||
assert.equal(result?.commitMarker?.storageTier, "chat-state");
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-generic-primary-no-mirror",
|
||
globalChatId: "chat-generic-primary-no-mirror",
|
||
characterId: "char-generic",
|
||
chatMetadata: {
|
||
integrity: "meta-generic-primary-no-mirror",
|
||
},
|
||
});
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-generic-primary-no-mirror", "generic-primary"),
|
||
{
|
||
revision: 5,
|
||
integrity: "meta-generic-primary-no-mirror",
|
||
chatId: "chat-generic-primary-no-mirror",
|
||
reason: "generic-primary-seed",
|
||
},
|
||
);
|
||
harness.api.setCurrentGraph(graph);
|
||
|
||
const result = await harness.api.persistExtractionBatchResult({
|
||
reason: "generic-primary-persist",
|
||
lastProcessedAssistantFloor: 6,
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(result.storageTier, "indexeddb");
|
||
assert.equal(
|
||
harness.runtimeContext.__chatContext.__chatStateStore.size,
|
||
0,
|
||
"generic ST 主写成功后不应再常驻 mirror 到 chat-state",
|
||
);
|
||
}
|
||
|
||
{
|
||
const harness = await createGraphPersistenceHarness({
|
||
chatId: "chat-luker-primary",
|
||
globalChatId: "chat-luker-primary",
|
||
characterId: "char-luker",
|
||
chatMetadata: {
|
||
integrity: "meta-luker-primary",
|
||
},
|
||
});
|
||
harness.runtimeContext.Luker = {
|
||
getContext() {
|
||
return harness.runtimeContext.__chatContext;
|
||
},
|
||
};
|
||
const graph = stampPersistedGraph(
|
||
createMeaningfulGraph("chat-luker-primary", "luker-primary"),
|
||
{
|
||
revision: 8,
|
||
integrity: "meta-luker-primary",
|
||
chatId: "chat-luker-primary",
|
||
reason: "luker-primary-seed",
|
||
},
|
||
);
|
||
harness.api.setCurrentGraph(graph);
|
||
|
||
const result = await harness.api.persistExtractionBatchResult({
|
||
reason: "luker-primary-persist",
|
||
lastProcessedAssistantFloor: 6,
|
||
});
|
||
|
||
assert.equal(result.accepted, true);
|
||
assert.equal(result.storageTier, "luker-chat-state");
|
||
assert.equal(result.acceptedBy, "luker-chat-state");
|
||
|
||
const stored = await harness.runtimeContext.__chatContext.getChatState(
|
||
GRAPH_CHAT_STATE_NAMESPACE,
|
||
);
|
||
assert.equal(stored?.revision, result.revision);
|
||
assert.equal(stored?.storageTier, "luker-chat-state");
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
assert.equal(
|
||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision || 0) >= result.revision,
|
||
true,
|
||
"Luker 主存储成功后应异步补写本地缓存",
|
||
);
|
||
assert.equal(
|
||
harness.api.getGraphPersistenceState().acceptedStorageTier,
|
||
"luker-chat-state",
|
||
);
|
||
}
|
||
|
||
console.log("graph-persistence tests passed");
|