mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 14:20:35 +08:00
Merge branch 'Youzini-afk:main' into main
This commit is contained in:
@@ -50,7 +50,6 @@ assert.equal(defaultSettings.injectUserPovMemory, true);
|
||||
assert.equal(defaultSettings.injectObjectiveGlobalMemory, true);
|
||||
assert.equal(defaultSettings.enableCognitiveMemory, true);
|
||||
assert.equal(defaultSettings.enableSpatialAdjacency, true);
|
||||
assert.equal(defaultSettings.enableAiMonitor, false);
|
||||
assert.equal(defaultSettings.injectLowConfidenceObjectiveMemory, false);
|
||||
assert.equal(defaultSettings.enableStoryTimeline, true);
|
||||
assert.equal(defaultSettings.injectStoryTimeLabel, true);
|
||||
@@ -67,15 +66,18 @@ assert.equal(defaultSettings.worldInfoFilterMode, "default");
|
||||
assert.equal(defaultSettings.worldInfoFilterCustomKeywords, "");
|
||||
assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false);
|
||||
assert.equal(defaultSettings.embeddingTransportMode, "direct");
|
||||
assert.equal(defaultSettings.graphUseNativeLayout, false);
|
||||
assert.equal(defaultSettings.graphUseNativeLayout, true);
|
||||
assert.equal(defaultSettings.graphNativeLayoutThresholdNodes, 280);
|
||||
assert.equal(defaultSettings.graphNativeLayoutThresholdEdges, 1600);
|
||||
assert.equal(defaultSettings.graphNativeLayoutWorkerTimeoutMs, 260);
|
||||
assert.equal(defaultSettings.persistUseNativeDelta, false);
|
||||
assert.equal(defaultSettings.persistUseNativeDelta, true);
|
||||
assert.equal(defaultSettings.persistNativeDeltaThresholdRecords, 20000);
|
||||
assert.equal(defaultSettings.persistNativeDeltaThresholdStructuralDelta, 600);
|
||||
assert.equal(defaultSettings.persistNativeDeltaThresholdSerializedChars, 4000000);
|
||||
assert.equal(defaultSettings.persistNativeDeltaBridgeMode, "json");
|
||||
assert.equal(defaultSettings.loadUseNativeHydrate, true);
|
||||
assert.equal(defaultSettings.loadNativeHydrateThresholdRecords, 30000);
|
||||
assert.equal(defaultSettings.nativeRolloutVersion, 2);
|
||||
assert.equal(defaultSettings.nativeEngineFailOpen, true);
|
||||
assert.equal(defaultSettings.graphNativeForceDisable, false);
|
||||
assert.equal(defaultSettings.taskProfilesVersion, 3);
|
||||
@@ -114,4 +116,44 @@ assert.equal(
|
||||
defaultSettings.compressionEveryN,
|
||||
);
|
||||
|
||||
const migratedLegacyNativeDisabled = mergePersistedSettings({
|
||||
graphUseNativeLayout: false,
|
||||
persistUseNativeDelta: false,
|
||||
loadUseNativeHydrate: false,
|
||||
graphNativeForceDisable: true,
|
||||
});
|
||||
assert.equal(migratedLegacyNativeDisabled.graphUseNativeLayout, true);
|
||||
assert.equal(migratedLegacyNativeDisabled.persistUseNativeDelta, true);
|
||||
assert.equal(migratedLegacyNativeDisabled.loadUseNativeHydrate, true);
|
||||
assert.equal(migratedLegacyNativeDisabled.loadNativeHydrateThresholdRecords, 30000);
|
||||
assert.equal(migratedLegacyNativeDisabled.graphNativeForceDisable, true);
|
||||
assert.equal(migratedLegacyNativeDisabled.nativeRolloutVersion, 2);
|
||||
|
||||
const migratedVersionedManualNativeDisabled = mergePersistedSettings({
|
||||
nativeRolloutVersion: 2,
|
||||
graphUseNativeLayout: false,
|
||||
persistUseNativeDelta: false,
|
||||
loadUseNativeHydrate: false,
|
||||
graphNativeForceDisable: true,
|
||||
});
|
||||
assert.equal(migratedVersionedManualNativeDisabled.graphUseNativeLayout, false);
|
||||
assert.equal(migratedVersionedManualNativeDisabled.persistUseNativeDelta, false);
|
||||
assert.equal(migratedVersionedManualNativeDisabled.loadUseNativeHydrate, false);
|
||||
assert.equal(migratedVersionedManualNativeDisabled.graphNativeForceDisable, true);
|
||||
assert.equal(migratedVersionedManualNativeDisabled.nativeRolloutVersion, 2);
|
||||
|
||||
const migratedLegacyHydrateThresholdDefault = mergePersistedSettings({
|
||||
nativeRolloutVersion: 1,
|
||||
loadNativeHydrateThresholdRecords: 12000,
|
||||
});
|
||||
assert.equal(migratedLegacyHydrateThresholdDefault.loadNativeHydrateThresholdRecords, 30000);
|
||||
assert.equal(migratedLegacyHydrateThresholdDefault.nativeRolloutVersion, 2);
|
||||
|
||||
const preservedCustomHydrateThreshold = mergePersistedSettings({
|
||||
nativeRolloutVersion: 1,
|
||||
loadNativeHydrateThresholdRecords: 45000,
|
||||
});
|
||||
assert.equal(preservedCustomHydrateThreshold.loadNativeHydrateThresholdRecords, 45000);
|
||||
assert.equal(preservedCustomHydrateThreshold.nativeRolloutVersion, 2);
|
||||
|
||||
console.log("default-settings tests passed");
|
||||
|
||||
@@ -16,6 +16,7 @@ function createRuntime(persistResult) {
|
||||
};
|
||||
let processedHistoryUpdates = 0;
|
||||
let persistedGraphSnapshot = null;
|
||||
let lastPersistDeltaOptions = null;
|
||||
|
||||
return {
|
||||
graph,
|
||||
@@ -35,6 +36,22 @@ function createRuntime(persistResult) {
|
||||
cloneGraphSnapshot(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
},
|
||||
buildPersistDelta(_beforeSnapshot, _afterSnapshot, options = {}) {
|
||||
lastPersistDeltaOptions = { ...(options || {}) };
|
||||
return {
|
||||
upsertNodes: [],
|
||||
upsertEdges: [],
|
||||
deleteNodeIds: [],
|
||||
deleteEdgeIds: [],
|
||||
tombstones: [],
|
||||
countDelta: {
|
||||
nodes: 0,
|
||||
edges: 0,
|
||||
tombstones: 0,
|
||||
},
|
||||
runtimeMetaPatch: {},
|
||||
};
|
||||
},
|
||||
buildExtractionMessages() {
|
||||
return [{ seq: 5, role: "assistant", content: "测试消息" }];
|
||||
},
|
||||
@@ -101,6 +118,9 @@ function createRuntime(persistResult) {
|
||||
get persistedGraphSnapshot() {
|
||||
return persistedGraphSnapshot;
|
||||
},
|
||||
get lastPersistDeltaOptions() {
|
||||
return lastPersistDeltaOptions;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,7 +144,7 @@ function createRuntime(persistResult) {
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.historyAdvanceAllowed, false);
|
||||
assert.equal(runtime.processedHistoryUpdates, 0);
|
||||
assert.equal(runtime.processedHistoryUpdates, 1);
|
||||
assert.equal(
|
||||
runtime.graph.historyState.lastBatchStatus.persistence.outcome,
|
||||
"queued",
|
||||
@@ -133,6 +153,11 @@ function createRuntime(persistResult) {
|
||||
runtime.graph.historyState.lastBatchStatus.historyAdvanceAllowed,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
runtime.graph.historyState.lastBatchStatus.historyAdvanced,
|
||||
false,
|
||||
);
|
||||
assert.equal(runtime.graph.batchJournal.length, 0);
|
||||
assert.equal(
|
||||
runtime.persistedGraphSnapshot?.historyState?.lastProcessedAssistantFloor,
|
||||
5,
|
||||
@@ -212,4 +237,85 @@ function createRuntime(persistResult) {
|
||||
assert.equal(runtime.graph.historyState.lastBatchStatus.persistence, null);
|
||||
}
|
||||
|
||||
{
|
||||
const originalNativeBuilder = globalThis.__stBmeNativeBuildPersistDelta;
|
||||
globalThis.__stBmeNativeBuildPersistDelta = () => ({
|
||||
upsertNodes: [],
|
||||
upsertEdges: [],
|
||||
deleteNodeIds: [],
|
||||
deleteEdgeIds: [],
|
||||
tombstones: [],
|
||||
runtimeMetaPatch: {},
|
||||
});
|
||||
const runtime = createRuntime({
|
||||
saved: true,
|
||||
queued: false,
|
||||
blocked: false,
|
||||
accepted: true,
|
||||
reason: "indexeddb",
|
||||
revision: 9,
|
||||
saveMode: "indexeddb",
|
||||
storageTier: "indexeddb",
|
||||
});
|
||||
const result = await executeExtractionBatchController(runtime, {
|
||||
chat: [{ is_user: false, mes: "测试" }],
|
||||
startIdx: 5,
|
||||
endIdx: 5,
|
||||
settings: {
|
||||
persistUseNativeDelta: true,
|
||||
graphNativeForceDisable: false,
|
||||
nativeEngineFailOpen: true,
|
||||
persistNativeDeltaThresholdRecords: 123,
|
||||
persistNativeDeltaThresholdStructuralDelta: 45,
|
||||
persistNativeDeltaThresholdSerializedChars: 6789,
|
||||
persistNativeDeltaBridgeMode: "hash",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(runtime.lastPersistDeltaOptions.useNativeDelta, true);
|
||||
assert.equal(runtime.lastPersistDeltaOptions.nativeFailOpen, true);
|
||||
assert.equal(runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdRecords, 123);
|
||||
assert.equal(
|
||||
runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdStructuralDelta,
|
||||
45,
|
||||
);
|
||||
assert.equal(
|
||||
runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdSerializedChars,
|
||||
6789,
|
||||
);
|
||||
assert.equal(runtime.lastPersistDeltaOptions.persistNativeDeltaBridgeMode, "hash");
|
||||
|
||||
if (typeof originalNativeBuilder === "function") {
|
||||
globalThis.__stBmeNativeBuildPersistDelta = originalNativeBuilder;
|
||||
} else {
|
||||
delete globalThis.__stBmeNativeBuildPersistDelta;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const runtime = createRuntime({
|
||||
saved: true,
|
||||
queued: false,
|
||||
blocked: false,
|
||||
accepted: true,
|
||||
reason: "indexeddb",
|
||||
revision: 10,
|
||||
saveMode: "indexeddb",
|
||||
storageTier: "indexeddb",
|
||||
});
|
||||
const result = await executeExtractionBatchController(runtime, {
|
||||
chat: [{ is_user: false, mes: "测试" }],
|
||||
startIdx: 5,
|
||||
endIdx: 5,
|
||||
settings: {
|
||||
persistUseNativeDelta: true,
|
||||
graphNativeForceDisable: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(runtime.lastPersistDeltaOptions.useNativeDelta, false);
|
||||
}
|
||||
|
||||
console.log("extraction-persistence-gating tests passed");
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
buildBmeDbName,
|
||||
buildGraphFromSnapshot,
|
||||
buildPersistDelta,
|
||||
buildPersistDeltaFromGraphDirtyState,
|
||||
buildSnapshotFromGraph,
|
||||
evaluateNativeHydrateGate,
|
||||
evaluatePersistNativeDeltaGate,
|
||||
} from "../sync/bme-db.js";
|
||||
import { onMessageReceivedController } from "../host/event-binding.js";
|
||||
@@ -82,13 +84,18 @@ import {
|
||||
getGraphStats,
|
||||
getNode,
|
||||
serializeGraph,
|
||||
updateNode,
|
||||
} 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 {
|
||||
hasGraphPersistDirtyState,
|
||||
normalizeGraphRuntimeState,
|
||||
pruneGraphPersistDirtyState,
|
||||
} from "../runtime/runtime-state.js";
|
||||
import {
|
||||
defaultSettings,
|
||||
getPersistedSettingsSnapshot,
|
||||
@@ -1031,8 +1038,12 @@ async function createGraphPersistenceHarness({
|
||||
__contextImmediateSaveCalls: 0,
|
||||
buildGraphFromSnapshot,
|
||||
buildPersistDelta,
|
||||
buildPersistDeltaFromGraphDirtyState,
|
||||
buildSnapshotFromGraph,
|
||||
evaluateNativeHydrateGate,
|
||||
evaluatePersistNativeDeltaGate,
|
||||
hasGraphPersistDirtyState,
|
||||
pruneGraphPersistDirtyState,
|
||||
buildBmeDbName,
|
||||
BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto",
|
||||
BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB: "indexeddb",
|
||||
@@ -3199,6 +3210,10 @@ result = {
|
||||
lastPersistedRevision: 0,
|
||||
writesBlocked: false,
|
||||
});
|
||||
harness.runtimeContext.extension_settings[MODULE_NAME] = {
|
||||
nativeRolloutVersion: 1,
|
||||
persistUseNativeDelta: false,
|
||||
};
|
||||
harness.runtimeContext.__scheduleUploadShouldThrow = true;
|
||||
|
||||
const result = await harness.api.saveGraphToIndexedDb(
|
||||
@@ -3234,6 +3249,268 @@ result = {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-idb-single-snapshot-build",
|
||||
globalChatId: "chat-idb-single-snapshot-build",
|
||||
chatMetadata: {
|
||||
integrity: "meta-idb-single-snapshot-build",
|
||||
},
|
||||
});
|
||||
harness.api.setCurrentGraph(
|
||||
createMeaningfulGraph("chat-idb-single-snapshot-build", "single-snapshot-build"),
|
||||
);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
chatId: "chat-idb-single-snapshot-build",
|
||||
revision: 8,
|
||||
lastPersistedRevision: 0,
|
||||
writesBlocked: false,
|
||||
});
|
||||
|
||||
const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph;
|
||||
let buildSnapshotCallCount = 0;
|
||||
harness.runtimeContext.buildSnapshotFromGraph = (...args) => {
|
||||
buildSnapshotCallCount += 1;
|
||||
return originalBuildSnapshotFromGraph(...args);
|
||||
};
|
||||
|
||||
const result = await harness.api.saveGraphToIndexedDb(
|
||||
"chat-idb-single-snapshot-build",
|
||||
harness.api.getCurrentGraph(),
|
||||
{
|
||||
revision: 8,
|
||||
reason: "single-snapshot-build-save",
|
||||
scheduleCloudUpload: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.saved, true);
|
||||
assert.equal(
|
||||
buildSnapshotCallCount,
|
||||
1,
|
||||
"saveGraphToIndexedDb 热路径应复用首次构建的 snapshot,而不是提交后再重建一次",
|
||||
);
|
||||
assert.equal(result.snapshot?.meta?.revision, 8);
|
||||
assert.equal(
|
||||
harness.api.getIndexedDbSnapshot()?.meta?.revision,
|
||||
8,
|
||||
"复用首次 snapshot 后仍应正确回填缓存 revision",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const chatId = "chat-idb-direct-delta-prebuilt-persist-snapshot";
|
||||
const baseGraph = createMeaningfulGraph(chatId, "direct-delta-base");
|
||||
const runtimeGraph = createMeaningfulGraph(chatId, "direct-delta-after");
|
||||
const baseSnapshot = buildSnapshotFromGraph(baseGraph, {
|
||||
chatId,
|
||||
revision: 7,
|
||||
});
|
||||
const persistSnapshot = buildSnapshotFromGraph(runtimeGraph, {
|
||||
chatId,
|
||||
revision: 8,
|
||||
baseSnapshot,
|
||||
});
|
||||
const directDelta = buildPersistDelta(baseSnapshot, persistSnapshot, {
|
||||
useNativeDelta: false,
|
||||
});
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId,
|
||||
globalChatId: chatId,
|
||||
chatMetadata: {
|
||||
integrity: "meta-idb-direct-delta-prebuilt-persist-snapshot",
|
||||
},
|
||||
indexedDbSnapshot: baseSnapshot,
|
||||
});
|
||||
harness.api.setCurrentGraph(runtimeGraph);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
chatId,
|
||||
revision: 8,
|
||||
lastPersistedRevision: 0,
|
||||
writesBlocked: false,
|
||||
});
|
||||
|
||||
const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph;
|
||||
let buildSnapshotCallCount = 0;
|
||||
harness.runtimeContext.buildSnapshotFromGraph = (...args) => {
|
||||
buildSnapshotCallCount += 1;
|
||||
return originalBuildSnapshotFromGraph(...args);
|
||||
};
|
||||
|
||||
const result = await harness.api.saveGraphToIndexedDb(chatId, runtimeGraph, {
|
||||
revision: 8,
|
||||
reason: "direct-delta-prebuilt-persist-snapshot-save",
|
||||
scheduleCloudUpload: false,
|
||||
persistDelta: directDelta,
|
||||
persistSnapshot,
|
||||
});
|
||||
|
||||
assert.equal(result.saved, true);
|
||||
assert.equal(
|
||||
buildSnapshotCallCount,
|
||||
0,
|
||||
"direct-delta 且已提供 persistSnapshot 时不应再次构建 snapshot",
|
||||
);
|
||||
assert.equal(result.snapshot?.meta?.revision, 8);
|
||||
assert.equal(harness.api.getIndexedDbSnapshot()?.meta?.revision, 8);
|
||||
}
|
||||
|
||||
{
|
||||
const chatId = "chat-idb-dirty-runtime-fast-path";
|
||||
const baseGraph = createMeaningfulGraph(chatId, "dirty-runtime-base");
|
||||
const runtimeGraph = cloneGraphForPersistence(baseGraph, chatId);
|
||||
updateNode(runtimeGraph, runtimeGraph.nodes[0]?.id, {
|
||||
importance: Number(runtimeGraph.nodes[0]?.importance || 0) + 2,
|
||||
});
|
||||
const baseSnapshot = buildSnapshotFromGraph(baseGraph, {
|
||||
chatId,
|
||||
revision: 7,
|
||||
});
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId,
|
||||
globalChatId: chatId,
|
||||
chatMetadata: {
|
||||
integrity: "meta-idb-dirty-runtime-fast-path",
|
||||
},
|
||||
indexedDbSnapshot: baseSnapshot,
|
||||
});
|
||||
harness.api.setCurrentGraph(runtimeGraph);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
chatId,
|
||||
revision: 8,
|
||||
lastPersistedRevision: 0,
|
||||
writesBlocked: false,
|
||||
});
|
||||
|
||||
const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph;
|
||||
let buildSnapshotCallCount = 0;
|
||||
harness.runtimeContext.buildSnapshotFromGraph = (...args) => {
|
||||
buildSnapshotCallCount += 1;
|
||||
return originalBuildSnapshotFromGraph(...args);
|
||||
};
|
||||
|
||||
const result = await harness.api.saveGraphToIndexedDb(chatId, runtimeGraph, {
|
||||
revision: 8,
|
||||
reason: "dirty-runtime-fast-path-save",
|
||||
scheduleCloudUpload: false,
|
||||
sourceGraph: runtimeGraph,
|
||||
});
|
||||
|
||||
assert.equal(result.saved, true);
|
||||
assert.equal(
|
||||
buildSnapshotCallCount,
|
||||
0,
|
||||
"dirty-set 命中时 saveGraphToIndexedDb 不应退回 full snapshot build",
|
||||
);
|
||||
assert.equal(result.snapshot?.meta?.revision, 8);
|
||||
assert.equal(harness.api.getIndexedDbSnapshot()?.meta?.revision, 8);
|
||||
}
|
||||
|
||||
{
|
||||
const chatId = "chat-indexeddb-probe-empty-early-return";
|
||||
const persistedSnapshot = {
|
||||
meta: { revision: 0, chatId },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: -1,
|
||||
extractionCount: 0,
|
||||
},
|
||||
};
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId,
|
||||
globalChatId: chatId,
|
||||
chatMetadata: {
|
||||
integrity: "meta-indexeddb-probe-empty-early-return",
|
||||
},
|
||||
indexedDbSnapshot: persistedSnapshot,
|
||||
});
|
||||
harness.runtimeContext.__globalChatId = chatId;
|
||||
harness.runtimeContext.__chatContext.chatId = chatId;
|
||||
harness.api.setChatContext({
|
||||
...harness.api.getChatContext(),
|
||||
chatId,
|
||||
chatMetadata: {
|
||||
integrity: "meta-indexeddb-probe-empty-early-return",
|
||||
},
|
||||
});
|
||||
harness.api.setCurrentGraph(
|
||||
createMeaningfulGraph(chatId, "probe-empty-runtime-current"),
|
||||
);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
chatId,
|
||||
revision: 1,
|
||||
lastPersistedRevision: 1,
|
||||
storagePrimary: "indexeddb",
|
||||
storageMode: "indexeddb",
|
||||
writesBlocked: false,
|
||||
});
|
||||
|
||||
const originalCreateDb = harness.runtimeContext.BmeChatManager.prototype._createDb;
|
||||
let exportSnapshotCalls = 0;
|
||||
let exportProbeCalls = 0;
|
||||
harness.runtimeContext.BmeChatManager.prototype._createDb = function(dbChatId = "") {
|
||||
const baseDb = originalCreateDb.call(this, dbChatId);
|
||||
return {
|
||||
...baseDb,
|
||||
async exportSnapshot() {
|
||||
exportSnapshotCalls += 1;
|
||||
return await baseDb.exportSnapshot();
|
||||
},
|
||||
async exportSnapshotProbe() {
|
||||
exportProbeCalls += 1;
|
||||
const snapshot = harness.api.getIndexedDbSnapshotForChat(dbChatId) || {
|
||||
meta: { revision: 0, chatId: String(dbChatId || "") },
|
||||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
};
|
||||
return {
|
||||
meta: {
|
||||
...(snapshot.meta || {}),
|
||||
chatId: String(dbChatId || ""),
|
||||
revision: Number(snapshot?.meta?.revision || 0),
|
||||
nodeCount: Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0,
|
||||
edgeCount: Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0,
|
||||
tombstoneCount: Array.isArray(snapshot?.tombstones)
|
||||
? snapshot.tombstones.length
|
||||
: 0,
|
||||
},
|
||||
state: {
|
||||
lastProcessedFloor: Number(snapshot?.state?.lastProcessedFloor ?? -1),
|
||||
extractionCount: Number(snapshot?.state?.extractionCount ?? 0),
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
__stBmeProbeOnly: true,
|
||||
__stBmeTombstonesOmitted: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const result = await harness.api.loadGraphFromIndexedDb(chatId, {
|
||||
source: "probe-empty-early-return",
|
||||
attemptIndex: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.loaded, false);
|
||||
assert.equal(exportProbeCalls, 1);
|
||||
assert.equal(
|
||||
exportSnapshotCalls,
|
||||
0,
|
||||
"empty/probe 早退应在 probe 阶段终止,而不是继续全量导出 snapshot",
|
||||
);
|
||||
harness.runtimeContext.BmeChatManager.prototype._createDb = originalCreateDb;
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-pending-persist-retry",
|
||||
@@ -3827,6 +4104,59 @@ result = {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-luker-queued-save-detached",
|
||||
globalChatId: "chat-luker-queued-save-detached",
|
||||
characterId: "char-luker-queued-save",
|
||||
chatMetadata: {
|
||||
integrity: "meta-luker-queued-save-detached",
|
||||
},
|
||||
});
|
||||
harness.runtimeContext.Luker = {
|
||||
getContext() {
|
||||
return harness.runtimeContext.__chatContext;
|
||||
},
|
||||
};
|
||||
harness.api.setCurrentGraph(
|
||||
stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-luker-queued-save-detached", "luker-detached"),
|
||||
{
|
||||
revision: 6,
|
||||
integrity: "meta-luker-queued-save-detached",
|
||||
chatId: "chat-luker-queued-save-detached",
|
||||
reason: "luker-detached-seed",
|
||||
},
|
||||
),
|
||||
);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
chatId: "chat-luker-queued-save-detached",
|
||||
revision: 6,
|
||||
lastPersistedRevision: 6,
|
||||
writesBlocked: false,
|
||||
});
|
||||
|
||||
const result = harness.api.saveGraphToChat({
|
||||
reason: "luker-detached-save",
|
||||
markMutation: false,
|
||||
});
|
||||
|
||||
assert.equal(result.queued, true);
|
||||
assert.equal(result.storageTier, "luker-chat-state");
|
||||
assert.equal(result.saveMode, "luker-chat-state-queued");
|
||||
|
||||
harness.api.getCurrentGraph().nodes[0].fields.title = "runtime-mutated-after-queued-save";
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
harness.api.getIndexedDbSnapshot()?.nodes?.[0]?.fields?.title,
|
||||
"事件-luker-detached",
|
||||
"Luker queued save 的异步本地 mirror 不应被后续 live graph 修改污染",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-luker-v2-load",
|
||||
|
||||
@@ -95,6 +95,8 @@ function resolveCurrentChatIdentity() {
|
||||
}
|
||||
function readCachedIndexedDbSnapshot() { return null; }
|
||||
function resolvePersistRevisionFloor(revision = 0) { return Number(revision) || 1; }
|
||||
function buildPersistDeltaFromGraphDirtyState() { return null; }
|
||||
function pruneGraphPersistDirtyState() { return null; }
|
||||
function buildSnapshotFromGraph(graph, options = {}) {
|
||||
return {
|
||||
meta: {
|
||||
@@ -123,6 +125,7 @@ function evaluatePersistNativeDeltaGate() {
|
||||
};
|
||||
}
|
||||
function readPersistDeltaDiagnosticsNow() { return Date.now(); }
|
||||
function normalizePersistDeltaDiagnosticsMs(value = 0) { return Math.round((Number(value) || 0) * 10) / 10; }
|
||||
function updatePersistDeltaDiagnostics() {}
|
||||
function buildPersistDelta() {
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BME_DB_SCHEMA_VERSION,
|
||||
BME_RUNTIME_BATCH_JOURNAL_META_KEY,
|
||||
BME_RUNTIME_HISTORY_META_KEY,
|
||||
BME_RUNTIME_RECORDS_NORMALIZED_META_KEY,
|
||||
BME_RUNTIME_VECTOR_META_KEY,
|
||||
BME_TOMBSTONE_RETENTION_MS,
|
||||
BmeDatabase,
|
||||
buildBmeDbName,
|
||||
@@ -11,6 +15,7 @@ import {
|
||||
} from "../sync/bme-db.js";
|
||||
import { BmeChatManager } from "../sync/bme-chat-manager.js";
|
||||
import { createEmptyGraph } from "../graph/graph.js";
|
||||
import { getGraphPersistDirtyStateSnapshot } from "../runtime/runtime-state.js";
|
||||
|
||||
const PREFIX = "[ST-BME][indexeddb-persistence]";
|
||||
|
||||
@@ -20,6 +25,7 @@ const chatIdsForCleanup = new Set([
|
||||
"chat-manager-a",
|
||||
"chat-manager-b",
|
||||
"chat-manager-selector",
|
||||
"chat-export-without-tombstones",
|
||||
"chat-replace-reset",
|
||||
]);
|
||||
|
||||
@@ -196,6 +202,84 @@ async function testSnapshotExportImport() {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testSnapshotExportWithoutTombstones() {
|
||||
const db = new BmeDatabase("chat-export-without-tombstones", {
|
||||
dexieClass: globalThis.Dexie,
|
||||
});
|
||||
await db.open();
|
||||
|
||||
await db.bulkUpsertNodes([
|
||||
{
|
||||
id: "node-light-snapshot",
|
||||
type: "event",
|
||||
sourceFloor: 3,
|
||||
archived: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
await db.bulkUpsertTombstones([
|
||||
{
|
||||
id: "tomb-light-snapshot",
|
||||
kind: "node",
|
||||
targetId: "node-deleted-light-snapshot",
|
||||
deletedAt: Date.now(),
|
||||
sourceDeviceId: "device-light-snapshot",
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await db.exportSnapshot({ includeTombstones: false });
|
||||
assert.equal(exported.__stBmeTombstonesOmitted, true);
|
||||
assert.ok(Array.isArray(exported.nodes));
|
||||
assert.ok(Array.isArray(exported.edges));
|
||||
assert.deepEqual(exported.tombstones, []);
|
||||
assert.equal(exported.meta.tombstoneCount, 1);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testSnapshotProbeExport() {
|
||||
const db = new BmeDatabase("chat-export-probe", {
|
||||
dexieClass: globalThis.Dexie,
|
||||
});
|
||||
await db.open();
|
||||
|
||||
await db.bulkUpsertNodes([
|
||||
{
|
||||
id: "node-probe",
|
||||
type: "event",
|
||||
sourceFloor: 4,
|
||||
archived: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
await db.patchMeta({
|
||||
lastProcessedFloor: 6,
|
||||
extractionCount: 3,
|
||||
runtimeHistoryState: {
|
||||
chatId: "chat-export-probe",
|
||||
lastProcessedAssistantFloor: 6,
|
||||
extractionCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const probe = await db.exportSnapshotProbe();
|
||||
assert.equal(probe.__stBmeProbeOnly, true);
|
||||
assert.equal(probe.__stBmeTombstonesOmitted, true);
|
||||
assert.deepEqual(probe.nodes, []);
|
||||
assert.deepEqual(probe.edges, []);
|
||||
assert.deepEqual(probe.tombstones, []);
|
||||
assert.equal(probe.meta.chatId, "chat-export-probe");
|
||||
assert.equal(probe.meta.nodeCount, 1);
|
||||
assert.equal(probe.state.lastProcessedFloor, 6);
|
||||
assert.equal(probe.state.extractionCount, 3);
|
||||
assert.equal(
|
||||
probe.meta.runtimeHistoryState.lastProcessedAssistantFloor,
|
||||
6,
|
||||
);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testReplaceImportResetsStaleMeta() {
|
||||
const chatId = "chat-replace-reset";
|
||||
const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
|
||||
@@ -532,32 +616,92 @@ async function testGraphSnapshotConverters() {
|
||||
id: "node-converter",
|
||||
type: "event",
|
||||
sourceFloor: 9,
|
||||
fields: {
|
||||
title: "Converter Node",
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
embedding: [0.25, 0.5, 0.75],
|
||||
scope: {
|
||||
layer: "pov",
|
||||
ownerType: "character",
|
||||
ownerId: "hero",
|
||||
ownerName: "Hero",
|
||||
regionPrimary: "camp",
|
||||
regionPath: ["camp", "tent"],
|
||||
regionSecondary: ["forest"],
|
||||
},
|
||||
storyTime: {
|
||||
segmentId: "segment-1",
|
||||
label: "Dawn",
|
||||
tense: "ongoing",
|
||||
relation: "same",
|
||||
anchorLabel: "Night",
|
||||
confidence: "high",
|
||||
source: "derived",
|
||||
},
|
||||
storyTimeSpan: {
|
||||
startSegmentId: "segment-0",
|
||||
endSegmentId: "segment-1",
|
||||
startLabel: "Night",
|
||||
endLabel: "Dawn",
|
||||
mixed: false,
|
||||
source: "derived",
|
||||
},
|
||||
});
|
||||
|
||||
let snapshotDiagnostics = null;
|
||||
const snapshot = buildSnapshotFromGraph(graph, {
|
||||
chatId: "chat-a",
|
||||
revision: 17,
|
||||
onDiagnostics(snapshotValue) {
|
||||
snapshotDiagnostics = snapshotValue;
|
||||
},
|
||||
});
|
||||
assert.equal(snapshot.meta.chatId, "chat-a");
|
||||
assert.equal(snapshot.meta.revision, 17);
|
||||
assert.equal(snapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY], true);
|
||||
assert.equal(snapshot.state.lastProcessedFloor, 9);
|
||||
assert.equal(snapshot.state.extractionCount, 4);
|
||||
assert.equal(snapshot.nodes.length, 1);
|
||||
assert.equal(Number.isFinite(snapshotDiagnostics?.nodesMs), true);
|
||||
assert.equal(Number.isFinite(snapshotDiagnostics?.edgesMs), true);
|
||||
assert.equal(Number.isFinite(snapshotDiagnostics?.tombstonesMs), true);
|
||||
assert.equal(Number.isFinite(snapshotDiagnostics?.stateMs), true);
|
||||
assert.equal(Number.isFinite(snapshotDiagnostics?.metaMs), true);
|
||||
assert.equal(Number.isFinite(snapshotDiagnostics?.totalMs), true);
|
||||
assert.equal(snapshotDiagnostics?.nodeCount, 1);
|
||||
|
||||
let hydrateDiagnostics = null;
|
||||
const nextGraph = buildGraphFromSnapshot(snapshot, {
|
||||
chatId: "chat-a",
|
||||
onDiagnostics(snapshotValue) {
|
||||
hydrateDiagnostics = snapshotValue;
|
||||
},
|
||||
});
|
||||
assert.equal(hydrateDiagnostics?.success, true);
|
||||
assert.equal(Number.isFinite(hydrateDiagnostics?.nodesMs), true);
|
||||
assert.equal(Number.isFinite(hydrateDiagnostics?.edgesMs), true);
|
||||
assert.equal(Number.isFinite(hydrateDiagnostics?.runtimeMetaMs), true);
|
||||
assert.equal(Number.isFinite(hydrateDiagnostics?.stateMs), true);
|
||||
assert.equal(Number.isFinite(hydrateDiagnostics?.normalizeMs), true);
|
||||
assert.equal(Number.isFinite(hydrateDiagnostics?.integrityMs), true);
|
||||
assert.equal(Number.isFinite(hydrateDiagnostics?.totalMs), true);
|
||||
|
||||
let reusedSnapshotDiagnostics = null;
|
||||
const reusedSnapshot = buildSnapshotFromGraph(nextGraph, {
|
||||
chatId: "chat-a",
|
||||
revision: 18,
|
||||
baseSnapshot: snapshot,
|
||||
onDiagnostics(snapshotValue) {
|
||||
reusedSnapshotDiagnostics = snapshotValue;
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
reusedSnapshot.nodes[0],
|
||||
snapshot.nodes[0],
|
||||
"未变化节点应直接复用 baseSnapshot 记录对象",
|
||||
);
|
||||
assert.equal(reusedSnapshotDiagnostics?.reusedNodeCount, 1);
|
||||
nextGraph.nodes[0].updatedAt = Number(nextGraph.nodes[0].updatedAt || 0) + 1;
|
||||
const changedSnapshot = buildSnapshotFromGraph(nextGraph, {
|
||||
chatId: "chat-a",
|
||||
@@ -573,16 +717,126 @@ async function testGraphSnapshotConverters() {
|
||||
const rebuilt = buildGraphFromSnapshot(snapshot, {
|
||||
chatId: "chat-a",
|
||||
});
|
||||
const legacyCompatibleSnapshot = {
|
||||
...snapshot,
|
||||
meta: {
|
||||
...snapshot.meta,
|
||||
},
|
||||
};
|
||||
delete legacyCompatibleSnapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY];
|
||||
legacyCompatibleSnapshot.nodes = [
|
||||
{
|
||||
...legacyCompatibleSnapshot.nodes[0],
|
||||
scope: undefined,
|
||||
storyTime: undefined,
|
||||
storyTimeSpan: undefined,
|
||||
},
|
||||
];
|
||||
const rebuiltLegacyCompatible = buildGraphFromSnapshot(legacyCompatibleSnapshot, {
|
||||
chatId: "chat-a",
|
||||
});
|
||||
const malformedButFlaggedSnapshot = {
|
||||
...legacyCompatibleSnapshot,
|
||||
meta: {
|
||||
...legacyCompatibleSnapshot.meta,
|
||||
[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true,
|
||||
},
|
||||
};
|
||||
const rebuiltMalformedButFlagged = buildGraphFromSnapshot(malformedButFlaggedSnapshot, {
|
||||
chatId: "chat-a",
|
||||
});
|
||||
const scopeRepairSnapshot = {
|
||||
...snapshot,
|
||||
meta: {
|
||||
...snapshot.meta,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
...snapshot.nodes[0],
|
||||
scope: {
|
||||
layer: "objective",
|
||||
regionPrimary: "王都/钟楼",
|
||||
regionSecondary: "旧城区 / 集市 / 钟楼",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
delete scopeRepairSnapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY];
|
||||
const rebuiltScopeRepair = buildGraphFromSnapshot(scopeRepairSnapshot, {
|
||||
chatId: "chat-a",
|
||||
});
|
||||
const scopeRepairDirtyState = getGraphPersistDirtyStateSnapshot(
|
||||
rebuiltScopeRepair,
|
||||
);
|
||||
assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 9);
|
||||
assert.equal(rebuilt.historyState.extractionCount, 4);
|
||||
assert.equal(rebuilt.nodes.length, 1);
|
||||
assert.equal(rebuilt.nodes[0].id, "node-converter");
|
||||
assert.equal(rebuilt.nodes[0].scope?.ownerType, "character");
|
||||
assert.equal(rebuilt.nodes[0].scope?.regionPrimary, "camp");
|
||||
assert.equal(rebuilt.nodes[0].storyTime?.label, "Dawn");
|
||||
assert.equal(rebuilt.nodes[0].storyTimeSpan?.endLabel, "Dawn");
|
||||
assert.equal(rebuilt.vectorIndexState.hashToNodeId["vec-hash"], "node-converter");
|
||||
assert.equal(rebuilt.maintenanceJournal[0].id, "maintenance-1");
|
||||
assert.equal(rebuilt.knowledgeState.activeOwnerKey, "owner:hero");
|
||||
assert.equal(rebuilt.regionState.activeRegion, "camp");
|
||||
assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1");
|
||||
assert.equal(rebuilt.summaryState.entries[0].id, "summary-1");
|
||||
assert.equal(rebuiltLegacyCompatible.nodes[0].scope?.layer, "objective");
|
||||
assert.equal(rebuiltLegacyCompatible.nodes[0].storyTime?.tense, "unknown");
|
||||
assert.equal(rebuiltLegacyCompatible.nodes[0].storyTimeSpan?.mixed, false);
|
||||
assert.equal(rebuiltMalformedButFlagged.nodes[0].scope?.layer, "objective");
|
||||
assert.equal(rebuiltMalformedButFlagged.nodes[0].storyTime?.tense, "unknown");
|
||||
assert.equal(rebuiltMalformedButFlagged.nodes[0].storyTimeSpan?.mixed, false);
|
||||
assert.equal(rebuiltScopeRepair.nodes[0].scope?.regionPrimary, "钟楼");
|
||||
assert.deepEqual(rebuiltScopeRepair.nodes[0].scope?.regionPath, ["王都", "钟楼"]);
|
||||
assert.deepEqual(rebuiltScopeRepair.nodes[0].scope?.regionSecondary, [
|
||||
"旧城区",
|
||||
"集市",
|
||||
]);
|
||||
assert.equal(
|
||||
scopeRepairDirtyState?.nodeUpsertIds?.includes("node-converter"),
|
||||
true,
|
||||
);
|
||||
assert.equal(rebuiltScopeRepair.vectorIndexState?.dirty, true);
|
||||
assert.equal(
|
||||
rebuiltScopeRepair.vectorIndexState?.replayRequiredNodeIds?.includes(
|
||||
"node-converter",
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
rebuilt.nodes[0].fields.title = "Mutated Converter Node";
|
||||
rebuilt.nodes[0].embedding[0] = 99;
|
||||
rebuilt.historyState.processedMessageHashes[1] = "mutated-hash";
|
||||
rebuilt.vectorIndexState.hashToNodeId["vec-hash"] = "node-mutated";
|
||||
rebuilt.batchJournal[0].processedRange[0] = 99;
|
||||
|
||||
assert.equal(
|
||||
snapshot.nodes[0].fields.title,
|
||||
"Converter Node",
|
||||
"buildGraphFromSnapshot 不应复用 snapshot 节点的嵌套字段引用",
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.meta[BME_RUNTIME_HISTORY_META_KEY].processedMessageHashes[1],
|
||||
"hash-1",
|
||||
"buildGraphFromSnapshot 不应复用 snapshot historyState 的嵌套对象引用",
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.nodes[0].embedding[0],
|
||||
0.25,
|
||||
"buildGraphFromSnapshot 不应复用 snapshot 节点的数组字段引用",
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.meta[BME_RUNTIME_VECTOR_META_KEY].hashToNodeId["vec-hash"],
|
||||
"node-converter",
|
||||
"buildGraphFromSnapshot 不应复用 snapshot vectorState 的嵌套对象引用",
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.meta[BME_RUNTIME_BATCH_JOURNAL_META_KEY][0].processedRange[0],
|
||||
8,
|
||||
"buildGraphFromSnapshot 不应复用 snapshot batchJournal 的嵌套数组引用",
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -593,6 +847,8 @@ async function main() {
|
||||
await testCrudAndMeta();
|
||||
await testTransactionRollback();
|
||||
await testSnapshotExportImport();
|
||||
await testSnapshotExportWithoutTombstones();
|
||||
await testSnapshotProbeExport();
|
||||
await testReplaceImportResetsStaleMeta();
|
||||
await testRevisionMonotonicity();
|
||||
await testTombstonePrune();
|
||||
|
||||
@@ -298,6 +298,10 @@ async function testUploadPayloadMetaFirstAndDebounce() {
|
||||
assert.equal(uploadResult.uploaded, true);
|
||||
assert.equal(logs.uploadCalls, 1);
|
||||
assert.equal(logs.uploadChunkCalls > 0, true);
|
||||
assert.equal(Number.isFinite(uploadResult.timings?.exportMs), true);
|
||||
assert.equal(Number.isFinite(uploadResult.timings?.chunkUploadMs), true);
|
||||
assert.equal(Number.isFinite(uploadResult.timings?.manifestUploadMs), true);
|
||||
assert.equal(Number.isFinite(uploadResult.timings?.metaPatchMs), true);
|
||||
|
||||
const uploadedPayload = logs.uploadedPayloads[0].payload;
|
||||
assert.equal(uploadedPayload.formatVersion, 2);
|
||||
@@ -375,6 +379,10 @@ async function testDownloadImport() {
|
||||
const result = await download("chat-download", runtime);
|
||||
|
||||
assert.equal(result.downloaded, true);
|
||||
assert.equal(Number.isFinite(result.timings?.networkMs), true);
|
||||
assert.equal(Number.isFinite(result.timings?.importMs), true);
|
||||
assert.equal(Number.isFinite(result.timings?.metaPatchMs), true);
|
||||
assert.equal(Number.isFinite(result.timings?.hookMs), true);
|
||||
assert.equal(db.lastImportPayload.meta.revision, 12);
|
||||
assert.equal(db.lastImportPayload.nodes[0].id, "remote-node");
|
||||
assert.equal(db.lastImportPayload.meta.runtimeVectorIndexState.dirty, true);
|
||||
@@ -731,6 +739,10 @@ async function testManualBackupAndRestoreFlow() {
|
||||
|
||||
const backupResult = await backupToServer("chat-backup-flow", runtime);
|
||||
assert.equal(backupResult.backedUp, true);
|
||||
assert.equal(Number.isFinite(backupResult.timings?.exportMs), true);
|
||||
assert.equal(Number.isFinite(backupResult.timings?.uploadMs), true);
|
||||
assert.equal(Number.isFinite(backupResult.timings?.manifestWriteMs), true);
|
||||
assert.equal(Number.isFinite(backupResult.timings?.metaPatchMs), true);
|
||||
assert.equal(db.meta.get("syncDirty"), false);
|
||||
assert.ok(Number(db.meta.get("lastBackupUploadedAt")) > 0);
|
||||
assert.ok(String(db.meta.get("lastBackupFilename") || "").startsWith("ST-BME_backup_"));
|
||||
@@ -801,6 +813,12 @@ async function testManualBackupAndRestoreFlow() {
|
||||
|
||||
const restoreResult = await restoreFromServer("chat-backup-flow", runtime);
|
||||
assert.equal(restoreResult.restored, true);
|
||||
assert.equal(Number.isFinite(restoreResult.timings?.downloadMs), true);
|
||||
assert.equal(Number.isFinite(restoreResult.timings?.localExportMs), true);
|
||||
assert.equal(Number.isFinite(restoreResult.timings?.safetySnapshotMs), true);
|
||||
assert.equal(Number.isFinite(restoreResult.timings?.importMs), true);
|
||||
assert.equal(Number.isFinite(restoreResult.timings?.metaPatchMs), true);
|
||||
assert.equal(Number.isFinite(restoreResult.timings?.hookMs), true);
|
||||
assert.equal(db.snapshot.nodes[0].id, "local-node");
|
||||
assert.equal(db.snapshot.meta.runtimeBatchJournal.length, 4);
|
||||
assert.equal(db.snapshot.meta.maintenanceJournal.length, 0);
|
||||
@@ -963,6 +981,7 @@ async function testRestoreValidationDoesNotCreateSafetySnapshot() {
|
||||
const restoreResult = await restoreFromServer("chat-no-backup", runtime);
|
||||
assert.equal(restoreResult.restored, false);
|
||||
assert.equal(restoreResult.reason, "not-found");
|
||||
assert.equal(Number.isFinite(restoreResult.timings?.downloadMs), true);
|
||||
|
||||
const safetyStatus = await getRestoreSafetySnapshotStatus(
|
||||
"chat-no-backup",
|
||||
|
||||
@@ -329,6 +329,135 @@ async function testManualExtractIgnoresSupersededPendingPersistence() {
|
||||
assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认");
|
||||
}
|
||||
|
||||
async function testManualExtractContinuesWithRecoverablePendingPersistence() {
|
||||
let executeExtractionBatchCalls = 0;
|
||||
let assistantTurnCallCount = 0;
|
||||
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
|
||||
const context = {
|
||||
...createBaseStatusContext(),
|
||||
isExtracting: false,
|
||||
graphPersistenceState: {
|
||||
pendingPersist: true,
|
||||
lastAcceptedRevision: 0,
|
||||
queuedPersistRevision: 7,
|
||||
shadowSnapshotRevision: 7,
|
||||
lastRecoverableStorageTier: "shadow",
|
||||
},
|
||||
currentGraph: {
|
||||
historyState: {
|
||||
lastBatchStatus: {
|
||||
processedRange: [1, 1],
|
||||
persistence: {
|
||||
outcome: "queued",
|
||||
accepted: false,
|
||||
revision: 7,
|
||||
reason: "extraction-batch-complete:pending",
|
||||
storageTier: "shadow",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCurrentChatId() {
|
||||
return "chat-mobile";
|
||||
},
|
||||
getCurrentGraph() {
|
||||
return context.currentGraph;
|
||||
},
|
||||
getIsExtracting() {
|
||||
return context.isExtracting;
|
||||
},
|
||||
getGraphPersistenceState() {
|
||||
return {
|
||||
pendingPersist: true,
|
||||
lastAcceptedRevision: 0,
|
||||
queuedPersistRevision: 7,
|
||||
shadowSnapshotRevision: 7,
|
||||
lastRecoverableStorageTier: "shadow",
|
||||
};
|
||||
},
|
||||
ensureGraphMutationReady() {
|
||||
return true;
|
||||
},
|
||||
async recoverHistoryIfNeeded() {
|
||||
return true;
|
||||
},
|
||||
normalizeGraphRuntimeState(graph) {
|
||||
return graph;
|
||||
},
|
||||
setCurrentGraph(graph) {
|
||||
context.currentGraph = graph;
|
||||
},
|
||||
createEmptyGraph() {
|
||||
return {};
|
||||
},
|
||||
getContext() {
|
||||
return { chat };
|
||||
},
|
||||
getAssistantTurns() {
|
||||
assistantTurnCallCount += 1;
|
||||
return assistantTurnCallCount <= 2 ? [1] : [];
|
||||
},
|
||||
getLastProcessedAssistantFloor() {
|
||||
return 0;
|
||||
},
|
||||
clampInt(value, fallback) {
|
||||
return Number.isFinite(Number(value)) ? Number(value) : fallback;
|
||||
},
|
||||
getSettings() {
|
||||
return { extractEvery: 1 };
|
||||
},
|
||||
beginStageAbortController() {
|
||||
return { signal: {} };
|
||||
},
|
||||
async executeExtractionBatch() {
|
||||
executeExtractionBatchCalls += 1;
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
newNodes: 0,
|
||||
updatedNodes: 0,
|
||||
newEdges: 0,
|
||||
},
|
||||
effects: {},
|
||||
batchStatus: {
|
||||
persistence: {
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
historyAdvanceAllowed: true,
|
||||
};
|
||||
},
|
||||
async retryPendingGraphPersist() {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: "shadow-still-pending",
|
||||
};
|
||||
},
|
||||
isAbortError() {
|
||||
return false;
|
||||
},
|
||||
onManualExtractController,
|
||||
finishStageAbortController() {},
|
||||
setIsExtracting(value) {
|
||||
context.isExtracting = value;
|
||||
},
|
||||
setLastExtractionStatus(text, meta, level) {
|
||||
context.lastExtractionStatus = { text, meta, level };
|
||||
context.runtimeStatus = { text, meta, level };
|
||||
},
|
||||
toastr: {
|
||||
info() {},
|
||||
success() {},
|
||||
warning() {},
|
||||
error() {},
|
||||
},
|
||||
result: null,
|
||||
};
|
||||
await onManualExtractController(context, { drainAll: false });
|
||||
assert.equal(executeExtractionBatchCalls, 1);
|
||||
assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认");
|
||||
}
|
||||
|
||||
async function testManualExtractIgnoresFailedBatchWithoutPersistenceAttempt() {
|
||||
let executeExtractionBatchCalls = 0;
|
||||
const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }];
|
||||
@@ -567,6 +696,7 @@ testIndexDefinesLastProcessedAssistantFloorHelper();
|
||||
await testVectorSyncTerminalStateUpdatesRuntime();
|
||||
await testManualExtractNoBatchesDoesNotStayRunning();
|
||||
await testManualExtractIgnoresSupersededPendingPersistence();
|
||||
await testManualExtractContinuesWithRecoverablePendingPersistence();
|
||||
await testManualExtractIgnoresFailedBatchWithoutPersistenceAttempt();
|
||||
await testManualRebuildSetsTerminalRuntimeStatus();
|
||||
|
||||
|
||||
57
tests/native-hydrate-failopen.mjs
Normal file
57
tests/native-hydrate-failopen.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
function moduleUrl(tag) {
|
||||
return `../vendor/wasm/stbme_core.js?test=${Date.now()}-${tag}`;
|
||||
}
|
||||
|
||||
globalThis.__stBmeDisableWasmPackArtifacts = true;
|
||||
delete globalThis.__stBmeLoadRustWasmLayout;
|
||||
|
||||
const firstLoad = await import(moduleUrl("native-hydrate-first"));
|
||||
let firstError = "";
|
||||
try {
|
||||
await firstLoad.installNativeHydrateHook();
|
||||
} catch (error) {
|
||||
firstError = error?.message || String(error);
|
||||
}
|
||||
|
||||
assert.match(
|
||||
firstError,
|
||||
/native module unavailable|native hydrate builder unavailable|global-loader|Rust\/WASM artifact is not initialized/i,
|
||||
);
|
||||
|
||||
globalThis.__stBmeLoadRustWasmLayout = async () => ({
|
||||
solve_layout() {
|
||||
return {
|
||||
ok: true,
|
||||
positions: [],
|
||||
diagnostics: {
|
||||
solver: "mock-rust-wasm",
|
||||
},
|
||||
};
|
||||
},
|
||||
build_hydrate_records() {
|
||||
return {
|
||||
ok: true,
|
||||
usedNative: true,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
diagnostics: {
|
||||
solver: "mock-rust-wasm",
|
||||
nodeCount: 0,
|
||||
edgeCount: 0,
|
||||
recordsNormalized: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const retryStatus = await firstLoad.installNativeHydrateHook();
|
||||
assert.equal(retryStatus.loaded, true);
|
||||
assert.equal(typeof globalThis.__stBmeNativeHydrateSnapshotRecords, "function");
|
||||
|
||||
delete globalThis.__stBmeNativeHydrateSnapshotRecords;
|
||||
delete globalThis.__stBmeLoadRustWasmLayout;
|
||||
delete globalThis.__stBmeDisableWasmPackArtifacts;
|
||||
|
||||
console.log("native-hydrate-failopen tests passed");
|
||||
260
tests/native-hydrate-hook.mjs
Normal file
260
tests/native-hydrate-hook.mjs
Normal file
@@ -0,0 +1,260 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BME_RUNTIME_HISTORY_META_KEY,
|
||||
BME_RUNTIME_RECORDS_NORMALIZED_META_KEY,
|
||||
BME_RUNTIME_VECTOR_META_KEY,
|
||||
buildGraphFromSnapshot,
|
||||
evaluateNativeHydrateGate,
|
||||
resolveNativeHydrateGateOptions,
|
||||
} from "../sync/bme-db.js";
|
||||
|
||||
function cloneValue(value) {
|
||||
if (typeof globalThis.structuredClone === "function") {
|
||||
return globalThis.structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
meta: {
|
||||
chatId: "chat-native-hydrate",
|
||||
revision: 3,
|
||||
[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true,
|
||||
[BME_RUNTIME_HISTORY_META_KEY]: {
|
||||
chatId: "chat-native-hydrate",
|
||||
lastProcessedAssistantFloor: 7,
|
||||
extractionCount: 2,
|
||||
processedMessageHashes: {},
|
||||
processedMessageHashVersion: 1,
|
||||
processedMessageHashesNeedRefresh: false,
|
||||
recentRecallOwnerKeys: [],
|
||||
activeRecallOwnerKey: "",
|
||||
activeRegion: "",
|
||||
activeRegionSource: "",
|
||||
activeStorySegmentId: "",
|
||||
activeStoryTimeLabel: "",
|
||||
activeStoryTimeSource: "",
|
||||
lastBatchStatus: null,
|
||||
lastMutationSource: "test",
|
||||
lastExtractedRegion: "",
|
||||
lastExtractedStorySegmentId: "",
|
||||
activeCharacterPovOwner: "",
|
||||
activeUserPovOwner: "",
|
||||
},
|
||||
[BME_RUNTIME_VECTOR_META_KEY]: {
|
||||
chatId: "chat-native-hydrate",
|
||||
collectionId: "",
|
||||
hashToNodeId: {},
|
||||
nodeToHash: {},
|
||||
replayRequiredNodeIds: [],
|
||||
dirty: false,
|
||||
dirtyReason: "",
|
||||
pendingRepairFromFloor: null,
|
||||
lastIntegrityIssue: null,
|
||||
lastStats: {
|
||||
nodesIndexed: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
state: {
|
||||
lastProcessedFloor: 7,
|
||||
extractionCount: 2,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "native-node-1",
|
||||
type: "event",
|
||||
updatedAt: 10,
|
||||
seqRange: [7, 7],
|
||||
childIds: [],
|
||||
clusters: [],
|
||||
fields: {
|
||||
title: "Native Node",
|
||||
},
|
||||
embedding: [1, 2, 3],
|
||||
scope: {
|
||||
layer: "pov",
|
||||
ownerType: "character",
|
||||
ownerId: "owner-1",
|
||||
ownerName: "",
|
||||
regionPrimary: "camp",
|
||||
regionPath: ["camp"],
|
||||
regionSecondary: [],
|
||||
},
|
||||
storyTime: {
|
||||
segmentId: "",
|
||||
label: "Dawn",
|
||||
tense: "unknown",
|
||||
relation: "unknown",
|
||||
anchorLabel: "",
|
||||
confidence: "medium",
|
||||
source: "derived",
|
||||
},
|
||||
storyTimeSpan: {
|
||||
startSegmentId: "",
|
||||
endSegmentId: "",
|
||||
startLabel: "Dawn",
|
||||
endLabel: "Dawn",
|
||||
mixed: false,
|
||||
source: "derived",
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "native-edge-1",
|
||||
fromId: "native-node-1",
|
||||
toId: "native-node-2",
|
||||
relation: "related",
|
||||
scope: {
|
||||
layer: "pov",
|
||||
ownerType: "character",
|
||||
ownerId: "owner-1",
|
||||
ownerName: "",
|
||||
regionPrimary: "camp",
|
||||
regionPath: ["camp"],
|
||||
regionSecondary: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
tombstones: [],
|
||||
};
|
||||
|
||||
const defaultGate = resolveNativeHydrateGateOptions({});
|
||||
assert.equal(defaultGate.minSnapshotRecords, 30000);
|
||||
const gatedSmall = evaluateNativeHydrateGate(snapshot, {});
|
||||
assert.equal(gatedSmall.allowed, false);
|
||||
assert.deepEqual(gatedSmall.reasons, ["below-min-snapshot-records"]);
|
||||
const gatedLarge = evaluateNativeHydrateGate(
|
||||
{
|
||||
nodes: new Array(15000).fill({ id: "node-x" }),
|
||||
edges: new Array(15000).fill({ id: "edge-x" }),
|
||||
},
|
||||
{},
|
||||
);
|
||||
assert.equal(gatedLarge.allowed, true);
|
||||
assert.deepEqual(gatedLarge.reasons, []);
|
||||
|
||||
const originalNativeBuilder = globalThis.__stBmeNativeHydrateSnapshotRecords;
|
||||
|
||||
globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => {
|
||||
assert.equal(options.recordsNormalized, true);
|
||||
return {
|
||||
ok: true,
|
||||
usedNative: true,
|
||||
nodes: cloneValue(snapshotView.nodes).map((node) => ({
|
||||
...node,
|
||||
nativeHydrated: true,
|
||||
})),
|
||||
edges: cloneValue(snapshotView.edges).map((edge) => ({
|
||||
...edge,
|
||||
nativeHydrated: true,
|
||||
})),
|
||||
diagnostics: {
|
||||
solver: "test-native-hydrate",
|
||||
nodeCount: Array.isArray(snapshotView.nodes) ? snapshotView.nodes.length : 0,
|
||||
edgeCount: Array.isArray(snapshotView.edges) ? snapshotView.edges.length : 0,
|
||||
recordsNormalized: options.recordsNormalized === true,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
let nativeDiagnostics = null;
|
||||
const rebuilt = buildGraphFromSnapshot(snapshot, {
|
||||
chatId: "chat-native-hydrate",
|
||||
useNativeHydrate: true,
|
||||
minSnapshotRecords: 0,
|
||||
onDiagnostics(snapshotValue) {
|
||||
nativeDiagnostics = snapshotValue;
|
||||
},
|
||||
});
|
||||
assert.equal(rebuilt.nodes[0].nativeHydrated, true);
|
||||
assert.equal(rebuilt.edges[0].nativeHydrated, true);
|
||||
assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 7);
|
||||
assert.equal(nativeDiagnostics.nativeRequested, true);
|
||||
assert.equal(nativeDiagnostics.nativeUsed, true);
|
||||
assert.equal(nativeDiagnostics.nativeStatus, "ok");
|
||||
assert.equal(nativeDiagnostics.nativeGateAllowed, true);
|
||||
assert.equal(nativeDiagnostics.nativeModuleDiagnostics?.solver, "test-native-hydrate");
|
||||
assert.equal(Number.isFinite(nativeDiagnostics.nativeRecordsMs), true);
|
||||
rebuilt.nodes[0].fields.title = "Mutated Native Node";
|
||||
rebuilt.nodes[0].embedding[0] = 99;
|
||||
assert.equal(snapshot.nodes[0].fields.title, "Native Node");
|
||||
assert.equal(snapshot.nodes[0].embedding[0], 1);
|
||||
|
||||
globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => {
|
||||
assert.equal(options.recordsNormalized, true);
|
||||
return {
|
||||
ok: true,
|
||||
usedNative: true,
|
||||
nodesJson: JSON.stringify(
|
||||
cloneValue(snapshotView.nodes).map((node) => ({
|
||||
...node,
|
||||
compactHydrated: true,
|
||||
})),
|
||||
),
|
||||
edgesJson: JSON.stringify(
|
||||
cloneValue(snapshotView.edges).map((edge) => ({
|
||||
...edge,
|
||||
compactHydrated: true,
|
||||
})),
|
||||
),
|
||||
diagnostics: {
|
||||
solver: "test-native-hydrate-compact",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
let compactDiagnostics = null;
|
||||
const compactGraph = buildGraphFromSnapshot(snapshot, {
|
||||
chatId: "chat-native-hydrate",
|
||||
useNativeHydrate: true,
|
||||
minSnapshotRecords: 0,
|
||||
onDiagnostics(snapshotValue) {
|
||||
compactDiagnostics = snapshotValue;
|
||||
},
|
||||
});
|
||||
assert.equal(compactGraph.nodes[0].compactHydrated, true);
|
||||
assert.equal(compactGraph.edges[0].compactHydrated, true);
|
||||
assert.equal(
|
||||
compactDiagnostics.nativeModuleDiagnostics?.hydrateBridgeMode,
|
||||
"compact-json",
|
||||
);
|
||||
|
||||
delete globalThis.__stBmeNativeHydrateSnapshotRecords;
|
||||
|
||||
let fallbackDiagnostics = null;
|
||||
const fallbackGraph = buildGraphFromSnapshot(snapshot, {
|
||||
chatId: "chat-native-hydrate",
|
||||
useNativeHydrate: true,
|
||||
minSnapshotRecords: 0,
|
||||
onDiagnostics(snapshotValue) {
|
||||
fallbackDiagnostics = snapshotValue;
|
||||
},
|
||||
});
|
||||
assert.equal(fallbackGraph.nodes.length, 1);
|
||||
assert.equal(fallbackDiagnostics.nativeRequested, true);
|
||||
assert.equal(fallbackDiagnostics.nativeUsed, false);
|
||||
assert.equal(fallbackDiagnostics.nativeStatus, "builder-unavailable");
|
||||
|
||||
let threwUnavailable = false;
|
||||
try {
|
||||
buildGraphFromSnapshot(snapshot, {
|
||||
chatId: "chat-native-hydrate",
|
||||
useNativeHydrate: true,
|
||||
minSnapshotRecords: 0,
|
||||
nativeFailOpen: false,
|
||||
});
|
||||
} catch (error) {
|
||||
threwUnavailable =
|
||||
String(error?.message || "") === "native-hydrate-builder-unavailable";
|
||||
}
|
||||
assert.equal(threwUnavailable, true);
|
||||
|
||||
if (typeof originalNativeBuilder === "function") {
|
||||
globalThis.__stBmeNativeHydrateSnapshotRecords = originalNativeBuilder;
|
||||
}
|
||||
|
||||
console.log("native-hydrate-hook tests passed");
|
||||
@@ -22,6 +22,24 @@ try {
|
||||
},
|
||||
};
|
||||
},
|
||||
build_hydrate_records(payload = {}) {
|
||||
return {
|
||||
ok: true,
|
||||
usedNative: true,
|
||||
nodes: Array.isArray(payload?.nodes)
|
||||
? payload.nodes.map((node) => ({ ...node, nativeHydrated: true }))
|
||||
: [],
|
||||
edges: Array.isArray(payload?.edges)
|
||||
? payload.edges.map((edge) => ({ ...edge, nativeHydrated: true }))
|
||||
: [],
|
||||
diagnostics: {
|
||||
solver: "mock-loader",
|
||||
nodeCount: Array.isArray(payload?.nodes) ? payload.nodes.length : 0,
|
||||
edgeCount: Array.isArray(payload?.edges) ? payload.edges.length : 0,
|
||||
recordsNormalized: payload?.recordsNormalized === true,
|
||||
},
|
||||
};
|
||||
},
|
||||
build_persist_delta_compact(payload = {}) {
|
||||
return {
|
||||
upsertNodeIds: Array.isArray(payload?.afterNodes?.ids)
|
||||
@@ -105,8 +123,29 @@ try {
|
||||
assert.deepEqual(deltaResult.upsertNodes, [{ id: "persist-native-node", marker: "after-chat" }]);
|
||||
assert.equal(deltaResult.runtimeMetaPatch.native, true);
|
||||
|
||||
const hydrateInstallStatus = await wrapper.installNativeHydrateHook();
|
||||
assert.equal(hydrateInstallStatus.loaded, true);
|
||||
assert.equal(
|
||||
typeof globalThis.__stBmeNativeHydrateSnapshotRecords,
|
||||
"function",
|
||||
);
|
||||
const hydrateResult = globalThis.__stBmeNativeHydrateSnapshotRecords(
|
||||
{
|
||||
nodes: [{ id: "hydrate-node", type: "event" }],
|
||||
edges: [{ id: "hydrate-edge", fromId: "hydrate-node", toId: "hydrate-node-2" }],
|
||||
},
|
||||
{
|
||||
recordsNormalized: true,
|
||||
},
|
||||
);
|
||||
assert.equal(hydrateResult.ok, true);
|
||||
assert.equal(hydrateResult.nodes[0].nativeHydrated, true);
|
||||
assert.equal(hydrateResult.edges[0].nativeHydrated, true);
|
||||
assert.equal(hydrateResult.diagnostics.recordsNormalized, true);
|
||||
|
||||
delete globalThis.__stBmeLoadRustWasmLayout;
|
||||
delete globalThis.__stBmeNativeBuildPersistDelta;
|
||||
delete globalThis.__stBmeNativeHydrateSnapshotRecords;
|
||||
delete globalThis.__stBmeDisableWasmPackArtifacts;
|
||||
|
||||
const wrapperNoLoader = await importFreshWrapper("no-loader");
|
||||
@@ -136,6 +175,7 @@ try {
|
||||
}
|
||||
delete globalThis.__stBmeDisableWasmPackArtifacts;
|
||||
delete globalThis.__stBmeNativeBuildPersistDelta;
|
||||
delete globalThis.__stBmeNativeHydrateSnapshotRecords;
|
||||
}
|
||||
|
||||
console.log("native-layout-wrapper tests passed");
|
||||
|
||||
153
tests/native-rollout-matrix.mjs
Normal file
153
tests/native-rollout-matrix.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
defaultSettings,
|
||||
mergePersistedSettings,
|
||||
} from "../runtime/settings-defaults.js";
|
||||
import {
|
||||
evaluateNativeHydrateGate,
|
||||
evaluatePersistNativeDeltaGate,
|
||||
resolveNativeHydrateGateOptions,
|
||||
resolvePersistNativeDeltaGateOptions,
|
||||
} from "../sync/bme-db.js";
|
||||
import {
|
||||
GraphNativeLayoutBridge,
|
||||
normalizeGraphNativeRuntimeOptions,
|
||||
} from "../ui/graph-native-bridge.js";
|
||||
|
||||
const migratedLegacy = mergePersistedSettings({
|
||||
graphUseNativeLayout: false,
|
||||
persistUseNativeDelta: false,
|
||||
loadUseNativeHydrate: false,
|
||||
});
|
||||
assert.equal(migratedLegacy.graphUseNativeLayout, true);
|
||||
assert.equal(migratedLegacy.persistUseNativeDelta, true);
|
||||
assert.equal(migratedLegacy.loadUseNativeHydrate, true);
|
||||
assert.equal(migratedLegacy.loadNativeHydrateThresholdRecords, 30000);
|
||||
assert.equal(migratedLegacy.nativeRolloutVersion, defaultSettings.nativeRolloutVersion);
|
||||
|
||||
const preservedManualOptOut = mergePersistedSettings({
|
||||
nativeRolloutVersion: defaultSettings.nativeRolloutVersion,
|
||||
graphUseNativeLayout: false,
|
||||
persistUseNativeDelta: false,
|
||||
loadUseNativeHydrate: false,
|
||||
graphNativeForceDisable: true,
|
||||
});
|
||||
assert.equal(preservedManualOptOut.graphUseNativeLayout, false);
|
||||
assert.equal(preservedManualOptOut.persistUseNativeDelta, false);
|
||||
assert.equal(preservedManualOptOut.loadUseNativeHydrate, false);
|
||||
assert.equal(preservedManualOptOut.graphNativeForceDisable, true);
|
||||
|
||||
const migratedLegacyHydrateThreshold = mergePersistedSettings({
|
||||
nativeRolloutVersion: 1,
|
||||
loadNativeHydrateThresholdRecords: 12000,
|
||||
});
|
||||
assert.equal(migratedLegacyHydrateThreshold.loadNativeHydrateThresholdRecords, 30000);
|
||||
|
||||
const preservedCustomHydrateThreshold = mergePersistedSettings({
|
||||
nativeRolloutVersion: 1,
|
||||
loadNativeHydrateThresholdRecords: 42000,
|
||||
});
|
||||
assert.equal(preservedCustomHydrateThreshold.loadNativeHydrateThresholdRecords, 42000);
|
||||
|
||||
const normalizedRuntimeOptions = normalizeGraphNativeRuntimeOptions({
|
||||
graphNativeLayoutThresholdNodes: 0,
|
||||
graphNativeLayoutThresholdEdges: 999999,
|
||||
graphNativeLayoutWorkerTimeoutMs: 10,
|
||||
nativeEngineFailOpen: 0,
|
||||
graphNativeForceDisable: "true",
|
||||
});
|
||||
assert.equal(normalizedRuntimeOptions.graphNativeLayoutThresholdNodes, 1);
|
||||
assert.equal(normalizedRuntimeOptions.graphNativeLayoutThresholdEdges, 50000);
|
||||
assert.equal(normalizedRuntimeOptions.graphNativeLayoutWorkerTimeoutMs, 40);
|
||||
assert.equal(normalizedRuntimeOptions.nativeEngineFailOpen, false);
|
||||
assert.equal(normalizedRuntimeOptions.graphNativeForceDisable, true);
|
||||
|
||||
const layoutBridge = new GraphNativeLayoutBridge({
|
||||
graphUseNativeLayout: true,
|
||||
graphNativeLayoutThresholdNodes: 280,
|
||||
graphNativeLayoutThresholdEdges: 1600,
|
||||
});
|
||||
assert.equal(layoutBridge.shouldRunForGraph(279, 1599), false);
|
||||
assert.equal(layoutBridge.shouldRunForGraph(280, 0), true);
|
||||
assert.equal(layoutBridge.shouldRunForGraph(0, 1600), true);
|
||||
layoutBridge.updateRuntimeOptions({ graphNativeForceDisable: true });
|
||||
assert.equal(layoutBridge.shouldRunForGraph(500, 5000), false);
|
||||
|
||||
const hydrateGateDefaults = resolveNativeHydrateGateOptions({});
|
||||
assert.equal(hydrateGateDefaults.minSnapshotRecords, 30000);
|
||||
|
||||
const hydrateBlocked = evaluateNativeHydrateGate(
|
||||
{ nodes: new Array(29999).fill({}), edges: [] },
|
||||
{ loadNativeHydrateThresholdRecords: 30000 },
|
||||
);
|
||||
assert.equal(hydrateBlocked.allowed, false);
|
||||
assert.deepEqual(hydrateBlocked.reasons, ["below-min-snapshot-records"]);
|
||||
assert.equal(hydrateBlocked.recordCount, 29999);
|
||||
|
||||
const hydrateAllowed = evaluateNativeHydrateGate(
|
||||
{ nodes: new Array(30000).fill({}), edges: [] },
|
||||
{ loadNativeHydrateThresholdRecords: 30000 },
|
||||
);
|
||||
assert.equal(hydrateAllowed.allowed, true);
|
||||
assert.deepEqual(hydrateAllowed.reasons, []);
|
||||
assert.equal(hydrateAllowed.recordCount, 30000);
|
||||
|
||||
const persistGateDefaults = resolvePersistNativeDeltaGateOptions({});
|
||||
assert.equal(persistGateDefaults.minSnapshotRecords, 20000);
|
||||
assert.equal(persistGateDefaults.minStructuralDelta, 600);
|
||||
assert.equal(persistGateDefaults.minCombinedSerializedChars, 4000000);
|
||||
|
||||
const persistBlocked = evaluatePersistNativeDeltaGate(
|
||||
{
|
||||
nodes: new Array(500).fill({}),
|
||||
edges: new Array(200).fill({}),
|
||||
tombstones: [],
|
||||
},
|
||||
{
|
||||
nodes: new Array(520).fill({}),
|
||||
edges: new Array(210).fill({}),
|
||||
tombstones: [],
|
||||
},
|
||||
{
|
||||
persistNativeDeltaThresholdRecords: 20000,
|
||||
persistNativeDeltaThresholdStructuralDelta: 600,
|
||||
persistNativeDeltaThresholdSerializedChars: 4000000,
|
||||
measuredCombinedSerializedChars: 1024,
|
||||
},
|
||||
);
|
||||
assert.equal(persistBlocked.allowed, false);
|
||||
assert.deepEqual(persistBlocked.reasons, [
|
||||
"below-record-threshold",
|
||||
"below-structural-delta-threshold",
|
||||
"below-serialized-chars-threshold",
|
||||
]);
|
||||
assert.equal(persistBlocked.maxSnapshotRecords, 730);
|
||||
assert.equal(persistBlocked.structuralDelta, 30);
|
||||
assert.equal(persistBlocked.combinedSerializedChars, 1024);
|
||||
|
||||
const persistAllowed = evaluatePersistNativeDeltaGate(
|
||||
{
|
||||
nodes: new Array(10000).fill({}),
|
||||
edges: new Array(10000).fill({}),
|
||||
tombstones: [],
|
||||
},
|
||||
{
|
||||
nodes: new Array(10400).fill({}),
|
||||
edges: new Array(10400).fill({}),
|
||||
tombstones: new Array(250).fill({}),
|
||||
},
|
||||
{
|
||||
persistNativeDeltaThresholdRecords: 20000,
|
||||
persistNativeDeltaThresholdStructuralDelta: 600,
|
||||
persistNativeDeltaThresholdSerializedChars: 4000000,
|
||||
measuredCombinedSerializedChars: 5000000,
|
||||
},
|
||||
);
|
||||
assert.equal(persistAllowed.allowed, true);
|
||||
assert.deepEqual(persistAllowed.reasons, []);
|
||||
assert.equal(persistAllowed.maxSnapshotRecords, 21050);
|
||||
assert.equal(persistAllowed.structuralDelta, 1050);
|
||||
assert.equal(persistAllowed.combinedSerializedChars, 5000000);
|
||||
|
||||
console.log("native-rollout-matrix tests passed");
|
||||
@@ -56,12 +56,18 @@ await store.patchMeta({
|
||||
lastProcessedFloor: 9,
|
||||
extractionCount: 4,
|
||||
});
|
||||
const probe = await store.exportSnapshotProbe();
|
||||
|
||||
assert.equal(
|
||||
loadSnapshotCalls,
|
||||
0,
|
||||
"manifest-only meta fast path should not load full snapshot",
|
||||
);
|
||||
assert.equal(probe.__stBmeProbeOnly, true);
|
||||
assert.equal(probe.meta.lastBackupFilename, "after.json");
|
||||
assert.equal(probe.meta.nodeCount, 1);
|
||||
assert.equal(probe.state.lastProcessedFloor, 9);
|
||||
assert.equal(probe.state.extractionCount, 4);
|
||||
|
||||
const snapshot = await originalLoadSnapshot();
|
||||
assert.equal(snapshot.meta.lastBackupFilename, "after.json");
|
||||
|
||||
@@ -152,9 +152,27 @@ function getNestedDirectory(directoryHandle, ...names) {
|
||||
return current;
|
||||
}
|
||||
|
||||
function readJsonFromDirectory(directoryHandle, filename) {
|
||||
async function readJsonFromDirectory(directoryHandle, filename, { retries = 5 } = {}) {
|
||||
assert.ok(directoryHandle.files.has(filename), `文件必须存在: ${filename}`);
|
||||
return JSON.parse(String(directoryHandle.files.get(filename) || ""));
|
||||
let lastError = null;
|
||||
let lastText = "";
|
||||
const normalizedRetries = Math.max(0, Math.floor(Number(retries) || 0));
|
||||
for (let attempt = 0; attempt <= normalizedRetries; attempt += 1) {
|
||||
lastText = String(directoryHandle.files.get(filename) || "");
|
||||
if (lastText) {
|
||||
try {
|
||||
return JSON.parse(lastText);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (attempt < normalizedRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`读取目录 JSON 失败: ${filename} len=${lastText.length} error=${String(lastError?.message || "empty")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildLegacyGraph(chatId) {
|
||||
@@ -288,7 +306,7 @@ async function testImportExportPersistenceAndFileRotation() {
|
||||
},
|
||||
);
|
||||
|
||||
const manifestAfterFirstImport = readJsonFromDirectory(chatDirectory, "manifest.json");
|
||||
const manifestAfterFirstImport = await readJsonFromDirectory(chatDirectory, "manifest.json");
|
||||
assert.equal(manifestAfterFirstImport.formatVersion, 2);
|
||||
assert.equal(manifestAfterFirstImport.baseRevision, 4);
|
||||
assert.equal(manifestAfterFirstImport.headRevision, 4);
|
||||
@@ -311,6 +329,12 @@ async function testImportExportPersistenceAndFileRotation() {
|
||||
assert.equal(firstExportedSnapshot.state.extractionCount, 2);
|
||||
assert.equal(firstExportedSnapshot.meta.storagePrimary, "opfs");
|
||||
assert.equal(firstExportedSnapshot.meta.storageMode, "opfs-primary");
|
||||
const lightweightSnapshot = await store.exportSnapshot({
|
||||
includeTombstones: false,
|
||||
});
|
||||
assert.equal(lightweightSnapshot.__stBmeTombstonesOmitted, true);
|
||||
assert.deepEqual(lightweightSnapshot.tombstones, []);
|
||||
assert.equal(lightweightSnapshot.meta.tombstoneCount, 1);
|
||||
assert.deepEqual(firstExportedSnapshot.meta[BME_RUNTIME_BATCH_JOURNAL_META_KEY], {
|
||||
pending: ["job-1"],
|
||||
});
|
||||
@@ -370,7 +394,7 @@ async function testImportExportPersistenceAndFileRotation() {
|
||||
},
|
||||
);
|
||||
|
||||
const manifestAfterSecondImport = readJsonFromDirectory(chatDirectory, "manifest.json");
|
||||
const manifestAfterSecondImport = await readJsonFromDirectory(chatDirectory, "manifest.json");
|
||||
assert.equal(manifestAfterSecondImport.formatVersion, 2);
|
||||
assert.equal(manifestAfterSecondImport.baseRevision, 6);
|
||||
assert.equal(manifestAfterSecondImport.headRevision, 6);
|
||||
|
||||
@@ -261,7 +261,62 @@ async function testGraphLikeDeltaPreservesHistoryFrontier() {
|
||||
);
|
||||
}
|
||||
|
||||
async function testCommitDeltaDiagnosticsSplitWalAndManifestStages() {
|
||||
const rootDirectory = createMemoryOpfsRoot();
|
||||
const store = new OpfsGraphStore("chat-opfs-diagnostics-split", {
|
||||
rootDirectoryFactory: async () => rootDirectory,
|
||||
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
});
|
||||
await store.open();
|
||||
|
||||
await store.importSnapshot(
|
||||
{
|
||||
meta: { revision: 1 },
|
||||
state: { lastProcessedFloor: 0, extractionCount: 0 },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
},
|
||||
{ mode: "replace", preserveRevision: true },
|
||||
);
|
||||
|
||||
const result = await store.commitDelta(
|
||||
{
|
||||
upsertNodes: [
|
||||
{
|
||||
id: "diag-node-1",
|
||||
type: "event",
|
||||
fields: { title: "diag" },
|
||||
archived: false,
|
||||
updatedAt: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
reason: "diagnostics-split",
|
||||
requestedRevision: 2,
|
||||
markSyncDirty: true,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(Number.isFinite(result.diagnostics?.walSerializeMs), true);
|
||||
assert.equal(Number.isFinite(result.diagnostics?.walFileWriteMs), true);
|
||||
assert.equal(Number.isFinite(result.diagnostics?.walWriteMs), true);
|
||||
assert.equal(Number.isFinite(result.diagnostics?.manifestSerializeMs), true);
|
||||
assert.equal(Number.isFinite(result.diagnostics?.manifestFileWriteMs), true);
|
||||
assert.equal(Number.isFinite(result.diagnostics?.manifestWriteMs), true);
|
||||
assert.equal(
|
||||
result.diagnostics.walWriteMs >= result.diagnostics.walSerializeMs,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
result.diagnostics.manifestWriteMs >= result.diagnostics.manifestSerializeMs,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
await testCommitDeltaAndPatchMetaSerialize();
|
||||
await testImportSnapshotAndClearAllSerialize();
|
||||
await testGraphLikeDeltaPreservesHistoryFrontier();
|
||||
await testCommitDeltaDiagnosticsSplitWalAndManifestStages();
|
||||
console.log("opfs-write-serialization tests passed");
|
||||
|
||||
@@ -4902,6 +4902,86 @@ async function testAutoExtractionDefersWhenHistoryRecoveryBusy() {
|
||||
assert.deepEqual(deferredReasons, ["history-recovering"]);
|
||||
}
|
||||
|
||||
async function testAutoExtractionContinuesWithRecoverablePendingPersistence() {
|
||||
const deferredReasons = [];
|
||||
const executeCalls = [];
|
||||
const currentGraph = {
|
||||
historyState: {
|
||||
lastBatchStatus: {
|
||||
processedRange: [1, 1],
|
||||
persistence: {
|
||||
outcome: "queued",
|
||||
accepted: false,
|
||||
revision: 7,
|
||||
reason: "extraction-batch-complete:pending",
|
||||
storageTier: "shadow",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runExtractionController({
|
||||
console,
|
||||
getIsExtracting: () => false,
|
||||
getCurrentGraph: () => currentGraph,
|
||||
getSettings: () => ({ enabled: true, extractEvery: 1 }),
|
||||
getContext: () => ({
|
||||
chat: [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }],
|
||||
}),
|
||||
getAssistantTurns: () => [1],
|
||||
getLastProcessedAssistantFloor: () => 0,
|
||||
getGraphPersistenceState: () => ({
|
||||
loadState: "loaded",
|
||||
pendingPersist: true,
|
||||
lastAcceptedRevision: 0,
|
||||
queuedPersistRevision: 7,
|
||||
shadowSnapshotRevision: 7,
|
||||
lastRecoverableStorageTier: "shadow",
|
||||
}),
|
||||
ensureGraphMutationReady: () => true,
|
||||
async retryPendingGraphPersist() {
|
||||
return {
|
||||
accepted: false,
|
||||
reason: "shadow-still-pending",
|
||||
};
|
||||
},
|
||||
async recoverHistoryIfNeeded() {
|
||||
return true;
|
||||
},
|
||||
deferAutoExtraction(reason) {
|
||||
deferredReasons.push(reason);
|
||||
},
|
||||
setIsExtracting() {},
|
||||
beginStageAbortController() {
|
||||
return { signal: {} };
|
||||
},
|
||||
setLastExtractionStatus() {},
|
||||
async executeExtractionBatch(options) {
|
||||
executeCalls.push(options);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
newNodes: 0,
|
||||
updatedNodes: 0,
|
||||
newEdges: 0,
|
||||
},
|
||||
batchStatus: {
|
||||
persistence: {
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
historyAdvanceAllowed: true,
|
||||
};
|
||||
},
|
||||
finishStageAbortController() {},
|
||||
isAbortError: () => false,
|
||||
notifyExtractionIssue() {},
|
||||
});
|
||||
|
||||
assert.equal(executeCalls.length, 1);
|
||||
assert.deepEqual(deferredReasons, []);
|
||||
}
|
||||
|
||||
async function testRemoveNodeHandlesCyclicChildGraph() {
|
||||
const graph = createEmptyGraph();
|
||||
const nodeA = addNode(
|
||||
@@ -7415,6 +7495,7 @@ await testLagModePendingResumeKeepsLockedPreviousAssistantAfterLatestDisappears(
|
||||
await testAutoExtractionDefersWhenGraphNotReady();
|
||||
await testAutoExtractionDefersWhenAlreadyExtracting();
|
||||
await testAutoExtractionDefersWhenHistoryRecoveryBusy();
|
||||
await testAutoExtractionContinuesWithRecoverablePendingPersistence();
|
||||
await testRemoveNodeHandlesCyclicChildGraph();
|
||||
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
|
||||
await testHistoryGenerationReusesPersistedRecallForStableUserFloor();
|
||||
|
||||
397
tests/perf/load-preapply-bench.mjs
Normal file
397
tests/perf/load-preapply-bench.mjs
Normal file
@@ -0,0 +1,397 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import {
|
||||
BmeDatabase,
|
||||
buildBmeDbName,
|
||||
buildGraphFromSnapshot,
|
||||
buildSnapshotFromGraph,
|
||||
ensureDexieLoaded,
|
||||
} from "../../sync/bme-db.js";
|
||||
import {
|
||||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
OpfsGraphStore,
|
||||
} from "../../sync/bme-opfs-store.js";
|
||||
import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
|
||||
|
||||
const RUNS = 4;
|
||||
const outputJson = process.argv.includes("--json");
|
||||
const projectRootHint = String(process.env.ST_BME_NODE_MODULES_ROOT || "").trim();
|
||||
const requireFromProjectRoot = projectRootHint
|
||||
? createRequire(path.join(projectRootHint, "package.json"))
|
||||
: null;
|
||||
const SIZE_PRESETS = [
|
||||
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600 },
|
||||
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800 },
|
||||
{ label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600 },
|
||||
];
|
||||
|
||||
async function importWithProjectRootFallback(specifier) {
|
||||
try {
|
||||
return await import(specifier);
|
||||
} catch (error) {
|
||||
if (!requireFromProjectRoot) {
|
||||
throw error;
|
||||
}
|
||||
const resolved = requireFromProjectRoot.resolve(specifier);
|
||||
return await import(pathToFileURL(resolved).href);
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(values = []) {
|
||||
if (!values.length) {
|
||||
return { avg: 0, p95: 0, min: 0, max: 0 };
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const sum = sorted.reduce((acc, value) => acc + value, 0);
|
||||
const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
|
||||
return {
|
||||
avg: sum / sorted.length,
|
||||
p95: sorted[p95Index],
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
function formatSummary(label, values = []) {
|
||||
const summary = summarize(values);
|
||||
return `${label} avg=${summary.avg.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms min=${summary.min.toFixed(2)}ms max=${summary.max.toFixed(2)}ms`;
|
||||
}
|
||||
|
||||
function createRandom(seed = 1) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 0xffffffff;
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeGraph(seed = 1, nodeCount = 100, edgeCount = 200, chatId = "bench-chat") {
|
||||
const rand = createRandom(seed);
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
for (let index = 0; index < nodeCount; index += 1) {
|
||||
nodes.push({
|
||||
id: `node-${index}`,
|
||||
type: "event",
|
||||
updatedAt: 1000 + index,
|
||||
archived: false,
|
||||
sourceFloor: index,
|
||||
fields: {
|
||||
title: `Node ${index}`,
|
||||
text: `node-${index}-${Math.floor(rand() * 100000)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < edgeCount; index += 1) {
|
||||
const fromIndex = Math.floor(rand() * nodeCount);
|
||||
let toIndex = Math.floor(rand() * nodeCount);
|
||||
if (toIndex === fromIndex) {
|
||||
toIndex = (toIndex + 1) % nodeCount;
|
||||
}
|
||||
edges.push({
|
||||
id: `edge-${index}`,
|
||||
fromId: `node-${fromIndex}`,
|
||||
toId: `node-${toIndex}`,
|
||||
relation: "related",
|
||||
strength: rand(),
|
||||
updatedAt: 2000 + index,
|
||||
});
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
nodes,
|
||||
edges,
|
||||
historyState: {
|
||||
chatId,
|
||||
lastProcessedAssistantFloor: Math.max(0, Math.floor(nodeCount / 12)),
|
||||
extractionCount: Math.max(1, Math.floor(nodeCount / 40)),
|
||||
processedMessageHashes: {},
|
||||
processedMessageHashVersion: 1,
|
||||
processedMessageHashesNeedRefresh: false,
|
||||
recentRecallOwnerKeys: [],
|
||||
activeRecallOwnerKey: "",
|
||||
activeRegion: "",
|
||||
activeRegionSource: "",
|
||||
activeStorySegmentId: "",
|
||||
activeStoryTimeLabel: "",
|
||||
activeStoryTimeSource: "",
|
||||
lastBatchStatus: null,
|
||||
lastMutationSource: "bench",
|
||||
lastExtractedRegion: "",
|
||||
lastExtractedStorySegmentId: "",
|
||||
activeCharacterPovOwner: "",
|
||||
activeUserPovOwner: "",
|
||||
},
|
||||
vectorIndexState: {
|
||||
chatId,
|
||||
collectionId: "",
|
||||
hashToNodeId: {},
|
||||
nodeToHash: {},
|
||||
replayRequiredNodeIds: [],
|
||||
dirty: false,
|
||||
dirtyReason: "",
|
||||
pendingRepairFromFloor: null,
|
||||
lastIntegrityIssue: null,
|
||||
lastStats: {
|
||||
nodesIndexed: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
},
|
||||
knowledgeState: {
|
||||
owners: {},
|
||||
activeOwnerKey: "",
|
||||
},
|
||||
regionState: {
|
||||
activeRegion: "",
|
||||
knownRegions: {},
|
||||
manualActiveRegion: "",
|
||||
},
|
||||
timelineState: {
|
||||
activeSegmentId: "",
|
||||
manualActiveSegmentId: "",
|
||||
segments: [],
|
||||
},
|
||||
summaryState: {
|
||||
updatedAt: 0,
|
||||
entries: [],
|
||||
},
|
||||
batchJournal: [],
|
||||
maintenanceJournal: [],
|
||||
lastRecallResult: null,
|
||||
lastProcessedSeq: Math.max(0, Math.floor(nodeCount / 12)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBenchSnapshot({ label, seed, nodeCount, edgeCount }) {
|
||||
const chatId = `load-bench-${label.toLowerCase()}-${seed}`;
|
||||
const graph = buildRuntimeGraph(seed, nodeCount, edgeCount, chatId);
|
||||
return {
|
||||
chatId,
|
||||
snapshot: buildSnapshotFromGraph(graph, {
|
||||
chatId,
|
||||
revision: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function setupIndexedDbTestEnv() {
|
||||
try {
|
||||
await importWithProjectRootFallback("fake-indexeddb/auto");
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
if (!globalThis.Dexie) {
|
||||
try {
|
||||
const imported = await importWithProjectRootFallback("dexie");
|
||||
globalThis.Dexie = imported?.default || imported?.Dexie || imported;
|
||||
} catch {
|
||||
await import("../../lib/dexie.min.js");
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDexieLoaded();
|
||||
}
|
||||
|
||||
async function cleanupDatabase(chatId = "") {
|
||||
if (!chatId || typeof globalThis.Dexie?.delete !== "function") return;
|
||||
try {
|
||||
await globalThis.Dexie.delete(buildBmeDbName(chatId));
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareIndexedDb(chatId, snapshot) {
|
||||
await cleanupDatabase(chatId);
|
||||
const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
await db.importSnapshot(snapshot, {
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
markSyncDirty: false,
|
||||
});
|
||||
return db;
|
||||
}
|
||||
|
||||
async function prepareOpfsStore(chatId, snapshot) {
|
||||
const rootDirectory = createMemoryOpfsRoot();
|
||||
const store = new OpfsGraphStore(chatId, {
|
||||
rootDirectoryFactory: async () => rootDirectory,
|
||||
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
});
|
||||
await store.open();
|
||||
await store.importSnapshot(snapshot, {
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
markSyncDirty: false,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
async function readProbeOrFallback(store) {
|
||||
let inspectionSnapshot = null;
|
||||
let exportProbeMs = 0;
|
||||
let exportSnapshotMs = 0;
|
||||
let exportSource = "";
|
||||
|
||||
if (typeof store.exportSnapshotProbe === "function") {
|
||||
const probeStartedAt = performance.now();
|
||||
inspectionSnapshot = await store.exportSnapshotProbe({ includeTombstones: false });
|
||||
exportProbeMs = performance.now() - probeStartedAt;
|
||||
exportSource = "probe";
|
||||
}
|
||||
|
||||
if (!inspectionSnapshot) {
|
||||
const exportStartedAt = performance.now();
|
||||
inspectionSnapshot = await store.exportSnapshot({ includeTombstones: false });
|
||||
exportSnapshotMs = performance.now() - exportStartedAt;
|
||||
exportSource = "full-export";
|
||||
}
|
||||
|
||||
return {
|
||||
inspectionSnapshot,
|
||||
exportProbeMs,
|
||||
exportSnapshotMs,
|
||||
exportSource,
|
||||
};
|
||||
}
|
||||
|
||||
async function measureSuccessPreApply(store, chatId) {
|
||||
const startedAt = performance.now();
|
||||
const probeResult = await readProbeOrFallback(store);
|
||||
let snapshot = probeResult.inspectionSnapshot;
|
||||
let exportSnapshotMs = probeResult.exportSnapshotMs;
|
||||
let exportSource = probeResult.exportSource;
|
||||
|
||||
if (snapshot?.__stBmeProbeOnly === true) {
|
||||
const exportStartedAt = performance.now();
|
||||
snapshot = await store.exportSnapshot({ includeTombstones: false });
|
||||
exportSnapshotMs += performance.now() - exportStartedAt;
|
||||
exportSource =
|
||||
probeResult.exportSource === "probe" ? "probe+full-export" : "full-export";
|
||||
}
|
||||
|
||||
const preApplyMs = performance.now() - startedAt;
|
||||
const hydrateStartedAt = performance.now();
|
||||
buildGraphFromSnapshot(snapshot, { chatId });
|
||||
const hydrateMs = performance.now() - hydrateStartedAt;
|
||||
|
||||
return {
|
||||
preApplyMs,
|
||||
exportProbeMs: probeResult.exportProbeMs,
|
||||
exportSnapshotMs,
|
||||
hydrateMs,
|
||||
exportSource,
|
||||
};
|
||||
}
|
||||
|
||||
async function measureProbeRejectPreApply(store) {
|
||||
const startedAt = performance.now();
|
||||
const probeResult = await readProbeOrFallback(store);
|
||||
return {
|
||||
preApplyMs: performance.now() - startedAt,
|
||||
exportProbeMs: probeResult.exportProbeMs,
|
||||
exportSnapshotMs: probeResult.exportSnapshotMs,
|
||||
exportSource: probeResult.exportSource,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPreset(preset) {
|
||||
const indexedDbSuccessSamples = [];
|
||||
const indexedDbProbeRejectSamples = [];
|
||||
const indexedDbProbeSamples = [];
|
||||
const indexedDbExportSamples = [];
|
||||
const indexedDbHydrateSamples = [];
|
||||
const opfsSuccessSamples = [];
|
||||
const opfsProbeRejectSamples = [];
|
||||
const opfsProbeSamples = [];
|
||||
const opfsExportSamples = [];
|
||||
const opfsHydrateSamples = [];
|
||||
|
||||
for (let run = 0; run < RUNS; run += 1) {
|
||||
const { chatId, snapshot } = buildBenchSnapshot({
|
||||
...preset,
|
||||
seed: preset.seed + run * 17,
|
||||
});
|
||||
|
||||
const indexedDbChatId = `${chatId}-indexeddb`;
|
||||
const db = await prepareIndexedDb(indexedDbChatId, snapshot);
|
||||
const indexedDbSuccess = await measureSuccessPreApply(db, indexedDbChatId);
|
||||
const indexedDbProbeReject = await measureProbeRejectPreApply(db);
|
||||
indexedDbSuccessSamples.push(indexedDbSuccess.preApplyMs);
|
||||
indexedDbProbeRejectSamples.push(indexedDbProbeReject.preApplyMs);
|
||||
indexedDbProbeSamples.push(indexedDbSuccess.exportProbeMs);
|
||||
indexedDbExportSamples.push(indexedDbSuccess.exportSnapshotMs);
|
||||
indexedDbHydrateSamples.push(indexedDbSuccess.hydrateMs);
|
||||
await db.close();
|
||||
await cleanupDatabase(indexedDbChatId);
|
||||
|
||||
const opfsChatId = `${chatId}-opfs`;
|
||||
const opfsStore = await prepareOpfsStore(opfsChatId, snapshot);
|
||||
const opfsSuccess = await measureSuccessPreApply(opfsStore, opfsChatId);
|
||||
const opfsProbeReject = await measureProbeRejectPreApply(opfsStore);
|
||||
opfsSuccessSamples.push(opfsSuccess.preApplyMs);
|
||||
opfsProbeRejectSamples.push(opfsProbeReject.preApplyMs);
|
||||
opfsProbeSamples.push(opfsSuccess.exportProbeMs);
|
||||
opfsExportSamples.push(opfsSuccess.exportSnapshotMs);
|
||||
opfsHydrateSamples.push(opfsSuccess.hydrateMs);
|
||||
await opfsStore.close();
|
||||
}
|
||||
|
||||
const result = {
|
||||
indexedDbPreApplySuccessMs: summarize(indexedDbSuccessSamples),
|
||||
indexedDbProbeRejectMs: summarize(indexedDbProbeRejectSamples),
|
||||
indexedDbExportProbeMs: summarize(indexedDbProbeSamples),
|
||||
indexedDbExportSnapshotMs: summarize(indexedDbExportSamples),
|
||||
indexedDbHydrateMs: summarize(indexedDbHydrateSamples),
|
||||
opfsPreApplySuccessMs: summarize(opfsSuccessSamples),
|
||||
opfsProbeRejectMs: summarize(opfsProbeRejectSamples),
|
||||
opfsExportProbeMs: summarize(opfsProbeSamples),
|
||||
opfsExportSnapshotMs: summarize(opfsExportSamples),
|
||||
opfsHydrateMs: summarize(opfsHydrateSamples),
|
||||
};
|
||||
|
||||
if (!outputJson) {
|
||||
console.log(`\n[ST-BME][load-preapply-bench] ${preset.label}`);
|
||||
console.log(
|
||||
formatSummary("indexeddb-preapply-success", indexedDbSuccessSamples),
|
||||
`probeRejectP95=${result.indexedDbProbeRejectMs.p95.toFixed(2)}ms`,
|
||||
`probeP95=${result.indexedDbExportProbeMs.p95.toFixed(2)}ms`,
|
||||
`exportP95=${result.indexedDbExportSnapshotMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("opfs-preapply-success", opfsSuccessSamples),
|
||||
`probeRejectP95=${result.opfsProbeRejectMs.p95.toFixed(2)}ms`,
|
||||
`probeP95=${result.opfsExportProbeMs.p95.toFixed(2)}ms`,
|
||||
`exportP95=${result.opfsExportSnapshotMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("indexeddb-hydrate", indexedDbHydrateSamples),
|
||||
formatSummary("opfs-hydrate", opfsHydrateSamples),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await setupIndexedDbTestEnv();
|
||||
const results = {};
|
||||
for (const preset of SIZE_PRESETS) {
|
||||
results[preset.label] = await runPreset(preset);
|
||||
}
|
||||
if (outputJson) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
runs: RUNS,
|
||||
presets: results,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
419
tests/perf/persist-load-bench.mjs
Normal file
419
tests/perf/persist-load-bench.mjs
Normal file
@@ -0,0 +1,419 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
import {
|
||||
buildGraphFromSnapshot,
|
||||
buildPersistDelta,
|
||||
buildSnapshotFromGraph,
|
||||
} from "../../sync/bme-db.js";
|
||||
import {
|
||||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
OpfsGraphStore,
|
||||
} from "../../sync/bme-opfs-store.js";
|
||||
import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
|
||||
|
||||
const RUNS = 4;
|
||||
const outputJson = process.argv.includes("--json");
|
||||
const useNativeHydrate = process.argv.includes("--native-hydrate");
|
||||
const nativeHydrateThresholdArg = process.argv.find((entry) =>
|
||||
String(entry || "").startsWith("--native-hydrate-threshold="),
|
||||
);
|
||||
const nativeHydrateThresholdRecords = nativeHydrateThresholdArg
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
Number(String(nativeHydrateThresholdArg).split("=").slice(1).join("=") || 0) || 0,
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
const SIZE_PRESETS = [
|
||||
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 },
|
||||
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 },
|
||||
{ label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600, churn: 0.12 },
|
||||
];
|
||||
|
||||
let nativeHydratePreloadStatus = useNativeHydrate ? "pending" : "not-requested";
|
||||
let nativeHydratePreloadError = "";
|
||||
|
||||
function summarize(values = []) {
|
||||
if (!values.length) {
|
||||
return { avg: 0, p95: 0, min: 0, max: 0 };
|
||||
}
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const sum = sorted.reduce((acc, value) => acc + value, 0);
|
||||
const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
|
||||
return {
|
||||
avg: sum / sorted.length,
|
||||
p95: sorted[p95Index],
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
function formatSummary(label, values = []) {
|
||||
const summary = summarize(values);
|
||||
return `${label} avg=${summary.avg.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms min=${summary.min.toFixed(2)}ms max=${summary.max.toFixed(2)}ms`;
|
||||
}
|
||||
|
||||
function createRandom(seed = 1) {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 0xffffffff;
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeGraph(seed = 1, nodeCount = 100, edgeCount = 200, chatId = "bench-chat") {
|
||||
const rand = createRandom(seed);
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
for (let index = 0; index < nodeCount; index += 1) {
|
||||
nodes.push({
|
||||
id: `node-${index}`,
|
||||
type: "event",
|
||||
updatedAt: 1000 + index,
|
||||
archived: false,
|
||||
sourceFloor: index,
|
||||
fields: {
|
||||
title: `Node ${index}`,
|
||||
text: `node-${index}-${Math.floor(rand() * 100000)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < edgeCount; index += 1) {
|
||||
const fromIndex = Math.floor(rand() * nodeCount);
|
||||
let toIndex = Math.floor(rand() * nodeCount);
|
||||
if (toIndex === fromIndex) {
|
||||
toIndex = (toIndex + 1) % nodeCount;
|
||||
}
|
||||
edges.push({
|
||||
id: `edge-${index}`,
|
||||
fromId: `node-${fromIndex}`,
|
||||
toId: `node-${toIndex}`,
|
||||
relation: "related",
|
||||
strength: rand(),
|
||||
updatedAt: 2000 + index,
|
||||
});
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
nodes,
|
||||
edges,
|
||||
historyState: {
|
||||
chatId,
|
||||
lastProcessedAssistantFloor: Math.max(0, Math.floor(nodeCount / 12)),
|
||||
extractionCount: Math.max(1, Math.floor(nodeCount / 40)),
|
||||
processedMessageHashes: {},
|
||||
processedMessageHashVersion: 1,
|
||||
processedMessageHashesNeedRefresh: false,
|
||||
recentRecallOwnerKeys: [],
|
||||
activeRecallOwnerKey: "",
|
||||
activeRegion: "",
|
||||
activeRegionSource: "",
|
||||
activeStorySegmentId: "",
|
||||
activeStoryTimeLabel: "",
|
||||
activeStoryTimeSource: "",
|
||||
lastBatchStatus: null,
|
||||
lastMutationSource: "bench",
|
||||
lastExtractedRegion: "",
|
||||
lastExtractedStorySegmentId: "",
|
||||
activeCharacterPovOwner: "",
|
||||
activeUserPovOwner: "",
|
||||
},
|
||||
vectorIndexState: {
|
||||
chatId,
|
||||
collectionId: "",
|
||||
hashToNodeId: {},
|
||||
nodeToHash: {},
|
||||
replayRequiredNodeIds: [],
|
||||
dirty: false,
|
||||
dirtyReason: "",
|
||||
pendingRepairFromFloor: null,
|
||||
lastIntegrityIssue: null,
|
||||
lastStats: {
|
||||
nodesIndexed: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
},
|
||||
knowledgeState: {
|
||||
owners: {},
|
||||
activeOwnerKey: "",
|
||||
},
|
||||
regionState: {
|
||||
activeRegion: "",
|
||||
knownRegions: {},
|
||||
manualActiveRegion: "",
|
||||
},
|
||||
timelineState: {
|
||||
activeSegmentId: "",
|
||||
manualActiveSegmentId: "",
|
||||
segments: [],
|
||||
},
|
||||
summaryState: {
|
||||
updatedAt: 0,
|
||||
entries: [],
|
||||
},
|
||||
batchJournal: [],
|
||||
maintenanceJournal: [],
|
||||
lastRecallResult: null,
|
||||
lastProcessedSeq: Math.max(0, Math.floor(nodeCount / 12)),
|
||||
};
|
||||
}
|
||||
|
||||
function mutateRuntimeGraph(baseGraph, seed = 1, churn = 0.1) {
|
||||
const rand = createRandom(seed);
|
||||
const nextGraph = structuredClone(baseGraph);
|
||||
const mutateNodeCount = Math.max(1, Math.floor(nextGraph.nodes.length * churn));
|
||||
const mutateEdgeCount = Math.max(1, Math.floor(nextGraph.edges.length * churn * 0.5));
|
||||
for (let index = 0; index < mutateNodeCount; index += 1) {
|
||||
const nodeIndex = Math.floor(rand() * nextGraph.nodes.length);
|
||||
const node = nextGraph.nodes[nodeIndex];
|
||||
node.updatedAt += 100 + index;
|
||||
node.fields.text = `${node.fields.text}-mut-${index}`;
|
||||
}
|
||||
for (let index = 0; index < mutateEdgeCount; index += 1) {
|
||||
const edgeIndex = Math.floor(rand() * nextGraph.edges.length);
|
||||
const edge = nextGraph.edges[edgeIndex];
|
||||
edge.updatedAt += 80 + index;
|
||||
edge.strength = rand();
|
||||
}
|
||||
const addNodeCount = Math.max(1, Math.floor(nextGraph.nodes.length * churn * 0.12));
|
||||
const baseNodeId = nextGraph.nodes.length;
|
||||
for (let index = 0; index < addNodeCount; index += 1) {
|
||||
nextGraph.nodes.push({
|
||||
id: `node-new-${baseNodeId + index}`,
|
||||
type: "event",
|
||||
updatedAt: 5000 + index,
|
||||
archived: false,
|
||||
sourceFloor: baseNodeId + index,
|
||||
fields: {
|
||||
title: `Node new ${index}`,
|
||||
text: `new-node-${index}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
const deleteEdgeCount = Math.max(1, Math.floor(nextGraph.edges.length * churn * 0.08));
|
||||
nextGraph.edges.splice(0, deleteEdgeCount);
|
||||
nextGraph.historyState.lastProcessedAssistantFloor += 1;
|
||||
nextGraph.historyState.extractionCount += 1;
|
||||
nextGraph.lastProcessedSeq = nextGraph.historyState.lastProcessedAssistantFloor;
|
||||
nextGraph.summaryState.updatedAt += 1;
|
||||
return nextGraph;
|
||||
}
|
||||
|
||||
function buildBenchPair({ label, seed, nodeCount, edgeCount, churn }) {
|
||||
const chatId = `bench-${label.toLowerCase()}`;
|
||||
const beforeGraph = buildRuntimeGraph(seed, nodeCount, edgeCount, chatId);
|
||||
const afterGraph = mutateRuntimeGraph(beforeGraph, seed + 101, churn);
|
||||
return {
|
||||
label,
|
||||
chatId,
|
||||
beforeGraph,
|
||||
afterGraph,
|
||||
};
|
||||
}
|
||||
|
||||
function measureSnapshotBuild(graph, options) {
|
||||
let diagnostics = null;
|
||||
const startedAt = performance.now();
|
||||
const snapshot = buildSnapshotFromGraph(graph, {
|
||||
...options,
|
||||
onDiagnostics(snapshotValue) {
|
||||
diagnostics = snapshotValue;
|
||||
},
|
||||
});
|
||||
return {
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
snapshot,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function measureHydrate(snapshot, chatId) {
|
||||
let diagnostics = null;
|
||||
const startedAt = performance.now();
|
||||
buildGraphFromSnapshot(snapshot, {
|
||||
chatId,
|
||||
useNativeHydrate,
|
||||
loadNativeHydrateThresholdRecords: nativeHydrateThresholdRecords,
|
||||
nativeFailOpen: true,
|
||||
onDiagnostics(snapshotValue) {
|
||||
diagnostics = snapshotValue;
|
||||
},
|
||||
});
|
||||
return {
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
async function measureOpfsCommit(baseSnapshot, afterSnapshot, delta, chatId) {
|
||||
const rootDirectory = createMemoryOpfsRoot();
|
||||
const store = new OpfsGraphStore(chatId, {
|
||||
rootDirectoryFactory: async () => rootDirectory,
|
||||
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
});
|
||||
await store.open();
|
||||
await store.importSnapshot(baseSnapshot, {
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
markSyncDirty: false,
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
const result = await store.commitDelta(delta, {
|
||||
reason: "bench-commit",
|
||||
requestedRevision: Number(afterSnapshot?.meta?.revision || 0),
|
||||
markSyncDirty: true,
|
||||
committedSnapshot: afterSnapshot,
|
||||
});
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
await store.close();
|
||||
return {
|
||||
elapsedMs,
|
||||
diagnostics: result?.diagnostics || {},
|
||||
};
|
||||
}
|
||||
|
||||
async function runPreset(preset) {
|
||||
const snapshotBuildSamples = [];
|
||||
const hydrateSamples = [];
|
||||
const opfsCommitSamples = [];
|
||||
const snapshotNodesSamples = [];
|
||||
const hydrateRuntimeMetaSamples = [];
|
||||
const hydrateNodesSamples = [];
|
||||
const hydrateEdgesSamples = [];
|
||||
const hydrateStateSamples = [];
|
||||
const hydrateNormalizeSamples = [];
|
||||
const hydrateIntegritySamples = [];
|
||||
const hydrateNativeRecordsSamples = [];
|
||||
const walFileWriteSamples = [];
|
||||
const manifestFileWriteSamples = [];
|
||||
let hydrateNativeUsedRuns = 0;
|
||||
|
||||
for (let run = 0; run < RUNS; run += 1) {
|
||||
const pair = buildBenchPair({
|
||||
...preset,
|
||||
seed: preset.seed + run * 17,
|
||||
});
|
||||
const beforeSnapshotResult = measureSnapshotBuild(pair.beforeGraph, {
|
||||
chatId: pair.chatId,
|
||||
revision: 1,
|
||||
});
|
||||
const afterSnapshotResult = measureSnapshotBuild(pair.afterGraph, {
|
||||
chatId: pair.chatId,
|
||||
revision: 2,
|
||||
baseSnapshot: beforeSnapshotResult.snapshot,
|
||||
});
|
||||
const delta = buildPersistDelta(
|
||||
beforeSnapshotResult.snapshot,
|
||||
afterSnapshotResult.snapshot,
|
||||
{ useNativeDelta: false },
|
||||
);
|
||||
const hydrateResult = measureHydrate(afterSnapshotResult.snapshot, pair.chatId);
|
||||
const opfsCommitResult = await measureOpfsCommit(
|
||||
beforeSnapshotResult.snapshot,
|
||||
afterSnapshotResult.snapshot,
|
||||
delta,
|
||||
pair.chatId,
|
||||
);
|
||||
|
||||
snapshotBuildSamples.push(afterSnapshotResult.elapsedMs);
|
||||
hydrateSamples.push(hydrateResult.elapsedMs);
|
||||
opfsCommitSamples.push(opfsCommitResult.elapsedMs);
|
||||
snapshotNodesSamples.push(Number(afterSnapshotResult.diagnostics?.nodesMs || 0));
|
||||
hydrateRuntimeMetaSamples.push(Number(hydrateResult.diagnostics?.runtimeMetaMs || 0));
|
||||
hydrateNodesSamples.push(Number(hydrateResult.diagnostics?.nodesMs || 0));
|
||||
hydrateEdgesSamples.push(Number(hydrateResult.diagnostics?.edgesMs || 0));
|
||||
hydrateStateSamples.push(Number(hydrateResult.diagnostics?.stateMs || 0));
|
||||
hydrateNormalizeSamples.push(Number(hydrateResult.diagnostics?.normalizeMs || 0));
|
||||
hydrateIntegritySamples.push(Number(hydrateResult.diagnostics?.integrityMs || 0));
|
||||
hydrateNativeRecordsSamples.push(
|
||||
Number(hydrateResult.diagnostics?.nativeRecordsMs || 0),
|
||||
);
|
||||
if (hydrateResult.diagnostics?.nativeUsed === true) {
|
||||
hydrateNativeUsedRuns += 1;
|
||||
}
|
||||
walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0));
|
||||
manifestFileWriteSamples.push(
|
||||
Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0),
|
||||
);
|
||||
}
|
||||
|
||||
const result = {
|
||||
snapshotBuildMs: summarize(snapshotBuildSamples),
|
||||
snapshotNodesMs: summarize(snapshotNodesSamples),
|
||||
hydrateMs: summarize(hydrateSamples),
|
||||
hydrateNodesMs: summarize(hydrateNodesSamples),
|
||||
hydrateEdgesMs: summarize(hydrateEdgesSamples),
|
||||
hydrateStateMs: summarize(hydrateStateSamples),
|
||||
hydrateNormalizeMs: summarize(hydrateNormalizeSamples),
|
||||
hydrateIntegrityMs: summarize(hydrateIntegritySamples),
|
||||
hydrateNativeRecordsMs: summarize(hydrateNativeRecordsSamples),
|
||||
hydrateNativeUsedRuns,
|
||||
nativeHydrateRequested: useNativeHydrate,
|
||||
nativeHydrateThresholdRecords:
|
||||
nativeHydrateThresholdRecords == null ? null : nativeHydrateThresholdRecords,
|
||||
hydrateRuntimeMetaMs: summarize(hydrateRuntimeMetaSamples),
|
||||
opfsCommitMs: summarize(opfsCommitSamples),
|
||||
opfsWalFileWriteMs: summarize(walFileWriteSamples),
|
||||
opfsManifestFileWriteMs: summarize(manifestFileWriteSamples),
|
||||
};
|
||||
if (!outputJson) {
|
||||
console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`);
|
||||
console.log(
|
||||
formatSummary("snapshot-build", snapshotBuildSamples),
|
||||
`nodesPhaseP95=${result.snapshotNodesMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("hydrate", hydrateSamples),
|
||||
`nodesP95=${result.hydrateNodesMs.p95.toFixed(2)}ms`,
|
||||
`edgesP95=${result.hydrateEdgesMs.p95.toFixed(2)}ms`,
|
||||
`normalizeP95=${result.hydrateNormalizeMs.p95.toFixed(2)}ms`,
|
||||
`integrityP95=${result.hydrateIntegrityMs.p95.toFixed(2)}ms`,
|
||||
`nativeRecordsP95=${result.hydrateNativeRecordsMs.p95.toFixed(2)}ms`,
|
||||
`nativeUsed=${result.hydrateNativeUsedRuns}/${RUNS}`,
|
||||
`runtimeMetaP95=${result.hydrateRuntimeMetaMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
formatSummary("opfs-commit", opfsCommitSamples),
|
||||
`walFileP95=${result.opfsWalFileWriteMs.p95.toFixed(2)}ms`,
|
||||
`manifestFileP95=${result.opfsManifestFileWriteMs.p95.toFixed(2)}ms`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (useNativeHydrate) {
|
||||
try {
|
||||
const nativeModule = await import("../../vendor/wasm/stbme_core.js");
|
||||
const nativeStatus = await nativeModule?.installNativeHydrateHook?.();
|
||||
nativeHydratePreloadStatus = nativeStatus?.loaded ? "loaded" : "not-loaded";
|
||||
nativeHydratePreloadError = String(nativeStatus?.error || "");
|
||||
} catch (error) {
|
||||
nativeHydratePreloadStatus = "failed";
|
||||
nativeHydratePreloadError = error?.message || String(error);
|
||||
console.warn(
|
||||
"[ST-BME][persist-load-bench] native hydrate preload failed, fallback to JS hydrate:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
const presets = {};
|
||||
for (const preset of SIZE_PRESETS) {
|
||||
presets[preset.label] = await runPreset(preset);
|
||||
}
|
||||
const payload = {
|
||||
runs: RUNS,
|
||||
nativeHydrateRequested: useNativeHydrate,
|
||||
nativeHydratePreloadStatus,
|
||||
nativeHydratePreloadError,
|
||||
nativeHydrateThresholdRecords:
|
||||
nativeHydrateThresholdRecords == null ? null : nativeHydrateThresholdRecords,
|
||||
presets,
|
||||
};
|
||||
if (outputJson) {
|
||||
console.log(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -69,16 +69,18 @@ const extractPromptBuild = await buildTaskPrompt(settings, "extract", {
|
||||
const extractPayload = buildTaskLlmPayload(extractPromptBuild, "fallback-user");
|
||||
assert.equal(extractPayload.systemPrompt, "");
|
||||
assert.equal(extractPayload.userPrompt, "");
|
||||
assert.equal(
|
||||
extractPayload.promptMessages.filter((message) => message.role === "user").length,
|
||||
2,
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractPayload.promptMessages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.blockName),
|
||||
["输出格式", "行为规则"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractPayload.promptMessages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) => message.blockName),
|
||||
["身份确认", "信息确认"],
|
||||
);
|
||||
const extractFormatBlock = extractPayload.promptMessages.find(
|
||||
(message) => message.blockName === "输出格式",
|
||||
);
|
||||
@@ -98,10 +100,10 @@ assert.deepEqual(
|
||||
[
|
||||
"charDescription",
|
||||
"userPersona",
|
||||
"recentMessages",
|
||||
"graphStats",
|
||||
"schema",
|
||||
"currentRange",
|
||||
"recentMessages",
|
||||
],
|
||||
);
|
||||
|
||||
@@ -118,9 +120,17 @@ const recallPromptBuild = await buildTaskPrompt(settings, "recall", {
|
||||
const recallPayload = buildTaskLlmPayload(recallPromptBuild, "fallback-user");
|
||||
assert.equal(recallPayload.systemPrompt, "");
|
||||
assert.equal(recallPayload.userPrompt, "");
|
||||
assert.equal(
|
||||
recallPayload.promptMessages.filter((message) => message.role === "user").length,
|
||||
2,
|
||||
assert.deepEqual(
|
||||
recallPayload.promptMessages
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.blockName),
|
||||
["输出格式", "行为规则"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
recallPayload.promptMessages
|
||||
.filter((message) => message.role === "assistant")
|
||||
.map((message) => message.blockName),
|
||||
["身份确认", "信息确认"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
recallPayload.promptMessages
|
||||
@@ -129,11 +139,11 @@ assert.deepEqual(
|
||||
[
|
||||
"charDescription",
|
||||
"userPersona",
|
||||
"graphStats",
|
||||
"sceneOwnerCandidates",
|
||||
"candidateNodes",
|
||||
"recentMessages",
|
||||
"userMessage",
|
||||
"candidateNodes",
|
||||
"sceneOwnerCandidates",
|
||||
"graphStats",
|
||||
],
|
||||
);
|
||||
const recallFormatBlock = recallPayload.promptMessages.find(
|
||||
|
||||
353
tests/recall-reroll-reuse.mjs
Normal file
353
tests/recall-reroll-reuse.mjs
Normal file
@@ -0,0 +1,353 @@
|
||||
// ST-BME: regression tests — reroll should reuse persisted recall record
|
||||
//
|
||||
// Covers:
|
||||
// 1. ensurePersistedRecallRecordForGeneration re-writes when existing record
|
||||
// has same injectionText/nodeIds but empty recallInput
|
||||
// 2. resolveReusablePersistedRecallRecord (inside runRecallController) reuses
|
||||
// a persisted record when recallInput matches the user floor text
|
||||
// 3. End-to-end: regenerate does NOT call retrieve when a valid persisted
|
||||
// record exists
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildPersistedRecallRecord,
|
||||
readPersistedRecallFromUserMessage,
|
||||
writePersistedRecallToUserMessage,
|
||||
BME_RECALL_EXTRA_KEY,
|
||||
} from "../retrieval/recall-persistence.js";
|
||||
import { runRecallController } from "../retrieval/recall-controller.js";
|
||||
import { createGenerationRecallHarness } from "./helpers/generation-recall-harness.mjs";
|
||||
import {
|
||||
normalizeRecallInputText,
|
||||
createRecallRunResult,
|
||||
createRecallInputRecord,
|
||||
isFreshRecallInputRecord,
|
||||
} from "../ui/ui-status.js";
|
||||
import { defaultSettings } from "../runtime/settings-defaults.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 1. ensurePersistedRecallRecordForGeneration: empty recallInput override
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const harness = await createGenerationRecallHarness({ realApplyFinal: true });
|
||||
|
||||
// Prime settings
|
||||
Object.assign(harness.settings, {
|
||||
...defaultSettings,
|
||||
enabled: true,
|
||||
recallEnabled: true,
|
||||
});
|
||||
|
||||
// Set up chat: user + assistant
|
||||
harness.chat = [
|
||||
{ is_user: true, mes: "去摩耶山看夜景" },
|
||||
{ is_user: false, mes: "好的,我们出发吧。", is_system: false },
|
||||
];
|
||||
|
||||
// Pre-write a persisted record with EMPTY recallInput (simulates old bug)
|
||||
const emptyRecallInputRecord = buildPersistedRecallRecord({
|
||||
injectionText: "注入:去摩耶山看夜景",
|
||||
selectedNodeIds: ["node-test-1"],
|
||||
recallInput: "",
|
||||
recallSource: "chat-tail-user",
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
tokenEstimate: 5,
|
||||
manuallyEdited: false,
|
||||
});
|
||||
writePersistedRecallToUserMessage(harness.chat, 0, emptyRecallInputRecord);
|
||||
|
||||
// Verify the record is written with empty recallInput
|
||||
const beforeRecord = readPersistedRecallFromUserMessage(harness.chat, 0);
|
||||
assert.ok(beforeRecord, "persisted record should exist before ensure");
|
||||
assert.equal(beforeRecord.recallInput, "", "recallInput should be empty before fix");
|
||||
assert.equal(
|
||||
beforeRecord.injectionText,
|
||||
"注入:去摩耶山看夜景",
|
||||
"injectionText should match",
|
||||
);
|
||||
|
||||
// Build a mock recall result with the same injectionText
|
||||
const mockRecallResult = {
|
||||
status: "completed",
|
||||
didRecall: true,
|
||||
ok: true,
|
||||
injectionText: "注入:去摩耶山看夜景",
|
||||
selectedNodeIds: ["node-test-1"],
|
||||
source: "chat-last-user",
|
||||
sourceLabel: "历史最后用户楼层",
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
authoritativeInputUsed: false,
|
||||
boundUserFloorText: "去摩耶山看夜景",
|
||||
};
|
||||
|
||||
// Build frozen recall options with overrideUserMessage
|
||||
const frozenRecallOptions = {
|
||||
generationType: "regenerate",
|
||||
targetUserMessageIndex: 0,
|
||||
overrideUserMessage: "去摩耶山看夜景",
|
||||
overrideSource: "chat-last-user",
|
||||
overrideSourceLabel: "历史最后用户楼层",
|
||||
lockedSource: "chat-last-user",
|
||||
lockedSourceLabel: "历史最后用户楼层",
|
||||
authoritativeInputUsed: false,
|
||||
boundUserFloorText: "去摩耶山看夜景",
|
||||
};
|
||||
|
||||
// Call ensurePersistedRecallRecordForGeneration
|
||||
const ensureResult = harness.result.ensurePersistedRecallRecordForGeneration({
|
||||
generationType: "regenerate",
|
||||
recallResult: mockRecallResult,
|
||||
transaction: { frozenRecallOptions },
|
||||
recallOptions: frozenRecallOptions,
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
});
|
||||
|
||||
// After fix: the record should be overwritten because existing recallInput is empty
|
||||
const afterRecord = readPersistedRecallFromUserMessage(harness.chat, 0);
|
||||
assert.ok(afterRecord, "persisted record should still exist after ensure");
|
||||
assert.equal(
|
||||
afterRecord.recallInput,
|
||||
"去摩耶山看夜景",
|
||||
"recallInput should now be populated after ensure overwrites empty-recallInput record",
|
||||
);
|
||||
assert.equal(
|
||||
afterRecord.boundUserFloorText,
|
||||
"去摩耶山看夜景",
|
||||
"boundUserFloorText should be populated",
|
||||
);
|
||||
|
||||
console.log(" ✓ ensurePersistedRecallRecordForGeneration overwrites record with empty recallInput");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 2. ensurePersistedRecallRecordForGeneration: populated recallInput skip
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Now the record has proper recallInput — calling ensure again should skip
|
||||
const ensureResult2 = harness.result.ensurePersistedRecallRecordForGeneration({
|
||||
generationType: "regenerate",
|
||||
recallResult: mockRecallResult,
|
||||
transaction: { frozenRecallOptions },
|
||||
recallOptions: frozenRecallOptions,
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
});
|
||||
assert.equal(
|
||||
ensureResult2.reason,
|
||||
"already-up-to-date",
|
||||
"should skip when recallInput is already populated",
|
||||
);
|
||||
|
||||
console.log(" ✓ ensurePersistedRecallRecordForGeneration skips when recallInput is populated");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 3. runRecallController: regenerate reuses persisted record
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Set up a fresh chat with a properly persisted recall record
|
||||
const rerollChat = [
|
||||
{ is_user: true, mes: "明日去摩耶山看夜景" },
|
||||
{ is_user: false, mes: "好的,明天约好了。", is_system: false },
|
||||
];
|
||||
|
||||
const validRecord = buildPersistedRecallRecord({
|
||||
injectionText: "注入:明日去摩耶山看夜景",
|
||||
selectedNodeIds: ["node-a"],
|
||||
recallInput: "明日去摩耶山看夜景",
|
||||
recallSource: "chat-tail-user",
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
tokenEstimate: 5,
|
||||
manuallyEdited: false,
|
||||
boundUserFloorText: "明日去摩耶山看夜景",
|
||||
});
|
||||
writePersistedRecallToUserMessage(rerollChat, 0, validRecord);
|
||||
|
||||
let retrieveCalled = false;
|
||||
const rerollRuntime = {
|
||||
getIsRecalling: () => false,
|
||||
getCurrentGraph: () => ({ nodes: [], edges: [] }),
|
||||
getSettings: () => ({
|
||||
...defaultSettings,
|
||||
enabled: true,
|
||||
recallEnabled: true,
|
||||
recallLlmContextMessages: 5,
|
||||
}),
|
||||
isGraphReadableForRecall: () => true,
|
||||
isGraphMetadataWriteAllowed: () => true,
|
||||
recoverHistoryIfNeeded: async () => true,
|
||||
getContext: () => ({ chat: rerollChat, chatId: "chat-reroll" }),
|
||||
nextRecallRunSequence: () => 1,
|
||||
beginStageAbortController: () => ({ signal: { aborted: false } }),
|
||||
finishStageAbortController: () => {},
|
||||
setIsRecalling: () => {},
|
||||
setActiveRecallPromise: () => {},
|
||||
getActiveRecallPromise: () => null,
|
||||
setLastRecallStatus: () => {},
|
||||
clampInt: (v, f, mn, mx) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return f;
|
||||
return Math.min(mx, Math.max(mn, Math.trunc(n)));
|
||||
},
|
||||
normalizeRecallInputText,
|
||||
createRecallInputRecord,
|
||||
createRecallRunResult,
|
||||
isFreshRecallInputRecord,
|
||||
getLatestUserChatMessage: (chat = []) =>
|
||||
[...chat].reverse().find((m) => m?.is_user) || null,
|
||||
getLastNonSystemChatMessage: (chat = []) =>
|
||||
[...chat].reverse().find((m) => !m?.is_system) || null,
|
||||
getRecallUserMessageSourceLabel: (s) => s,
|
||||
buildRecallRecentMessages: () => [],
|
||||
readPersistedRecallFromUserMessage,
|
||||
bumpPersistedRecallGenerationCount: (chat, idx) => {
|
||||
// no-op in test; just return the record
|
||||
return readPersistedRecallFromUserMessage(chat, idx);
|
||||
},
|
||||
triggerChatMetadataSave: () => {},
|
||||
schedulePersistedRecallMessageUiRefresh: () => {},
|
||||
refreshPanelLiveState: () => {},
|
||||
ensureVectorReadyIfNeeded: async () => {},
|
||||
resolveRecallInput: (chat, limit, override) => {
|
||||
// Simulate resolveRecallInputController override path
|
||||
const overrideText = normalizeRecallInputText(
|
||||
override?.overrideUserMessage || override?.userMessage || "",
|
||||
);
|
||||
return {
|
||||
userMessage: overrideText,
|
||||
generationType: String(override?.generationType || "normal"),
|
||||
targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex)
|
||||
? override.targetUserMessageIndex
|
||||
: null,
|
||||
source: override?.overrideSource || "chat-last-user",
|
||||
sourceLabel: override?.overrideSourceLabel || "历史最后用户楼层",
|
||||
reason: "override-bound",
|
||||
authoritativeInputUsed: Boolean(override?.authoritativeInputUsed),
|
||||
boundUserFloorText: normalizeRecallInputText(
|
||||
override?.boundUserFloorText || "",
|
||||
),
|
||||
recentMessages: [],
|
||||
hookName: override?.hookName || "",
|
||||
deliveryMode: "immediate",
|
||||
};
|
||||
},
|
||||
applyRecallInjection: (_settings, _input, _recent, result) => ({
|
||||
injectionText: result?.injectionText || "",
|
||||
applied: true,
|
||||
source: "persisted-reuse",
|
||||
mode: "module-injection",
|
||||
}),
|
||||
retrieve: async () => {
|
||||
retrieveCalled = true;
|
||||
return {
|
||||
injectionText: "should-not-appear",
|
||||
selectedNodeIds: ["node-b"],
|
||||
};
|
||||
},
|
||||
buildRecallRetrieveOptions: () => ({}),
|
||||
getEmbeddingConfig: () => ({}),
|
||||
getSchema: () => ({}),
|
||||
console,
|
||||
isAbortError: () => false,
|
||||
toastr: { error: () => {} },
|
||||
getRecallHookLabel: () => "",
|
||||
setPendingRecallSendIntent: () => {},
|
||||
};
|
||||
|
||||
// Simulate regenerate: override with the user floor text and generationType regenerate
|
||||
const rerollResult = await runRecallController(rerollRuntime, {
|
||||
overrideUserMessage: "明日去摩耶山看夜景",
|
||||
generationType: "regenerate",
|
||||
targetUserMessageIndex: 0,
|
||||
overrideSource: "chat-last-user",
|
||||
overrideSourceLabel: "历史最后用户楼层",
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
deliveryMode: "immediate",
|
||||
});
|
||||
|
||||
assert.equal(rerollResult.status, "completed", "reroll should complete");
|
||||
assert.equal(
|
||||
rerollResult.reason,
|
||||
"persisted-user-floor-reused",
|
||||
"reroll should reuse persisted record, not run fresh recall",
|
||||
);
|
||||
assert.equal(
|
||||
retrieveCalled,
|
||||
false,
|
||||
"retrieve() should NOT be called when persisted record is reused",
|
||||
);
|
||||
assert.equal(
|
||||
rerollResult.injectionText,
|
||||
"注入:明日去摩耶山看夜景",
|
||||
"injection text should come from persisted record",
|
||||
);
|
||||
|
||||
console.log(" ✓ runRecallController reuses persisted record on regenerate");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 4. runRecallController: regenerate with empty recallInput does NOT reuse
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const noReuseChat = [
|
||||
{ is_user: true, mes: "去看星星" },
|
||||
{ is_user: false, mes: "好的。", is_system: false },
|
||||
];
|
||||
const emptyInputRecord = buildPersistedRecallRecord({
|
||||
injectionText: "注入:去看星星",
|
||||
selectedNodeIds: ["node-c"],
|
||||
recallInput: "",
|
||||
recallSource: "chat-tail-user",
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
tokenEstimate: 3,
|
||||
manuallyEdited: false,
|
||||
});
|
||||
writePersistedRecallToUserMessage(noReuseChat, 0, emptyInputRecord);
|
||||
|
||||
let noReuseRetrieveCalled = false;
|
||||
const noReuseRuntime = {
|
||||
...rerollRuntime,
|
||||
getContext: () => ({ chat: noReuseChat, chatId: "chat-no-reuse" }),
|
||||
readPersistedRecallFromUserMessage,
|
||||
retrieve: async () => {
|
||||
noReuseRetrieveCalled = true;
|
||||
return {
|
||||
injectionText: "新召回结果",
|
||||
selectedNodeIds: ["node-d"],
|
||||
};
|
||||
},
|
||||
resolveRecallInput: (chat, limit, override) => ({
|
||||
userMessage: normalizeRecallInputText(
|
||||
override?.overrideUserMessage || "",
|
||||
),
|
||||
generationType: String(override?.generationType || "normal"),
|
||||
targetUserMessageIndex: Number.isFinite(override?.targetUserMessageIndex)
|
||||
? override.targetUserMessageIndex
|
||||
: null,
|
||||
source: override?.overrideSource || "chat-last-user",
|
||||
sourceLabel: override?.overrideSourceLabel || "",
|
||||
reason: "override-bound",
|
||||
authoritativeInputUsed: false,
|
||||
boundUserFloorText: "",
|
||||
recentMessages: [],
|
||||
hookName: override?.hookName || "",
|
||||
deliveryMode: "immediate",
|
||||
}),
|
||||
};
|
||||
|
||||
const noReuseResult = await runRecallController(noReuseRuntime, {
|
||||
overrideUserMessage: "去看星星",
|
||||
generationType: "regenerate",
|
||||
targetUserMessageIndex: 0,
|
||||
overrideSource: "chat-last-user",
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
deliveryMode: "immediate",
|
||||
});
|
||||
|
||||
assert.equal(noReuseResult.status, "completed", "no-reuse should complete");
|
||||
assert.equal(
|
||||
noReuseRetrieveCalled,
|
||||
true,
|
||||
"retrieve() SHOULD be called when persisted record has empty recallInput",
|
||||
);
|
||||
|
||||
console.log(" ✓ runRecallController does NOT reuse record with empty recallInput");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
console.log("recall-reroll-reuse tests passed");
|
||||
@@ -8,6 +8,15 @@ import {
|
||||
findLatestNode,
|
||||
serializeGraph,
|
||||
} from "../graph/graph.js";
|
||||
import {
|
||||
buildRegionLine,
|
||||
getScopeRegionTokens,
|
||||
normalizeMemoryScope,
|
||||
} from "../graph/memory-scope.js";
|
||||
import {
|
||||
normalizeStoryTime,
|
||||
normalizeStoryTimeSpan,
|
||||
} from "../graph/story-timeline.js";
|
||||
|
||||
const graph = createEmptyGraph();
|
||||
const objectiveNode = createNode({
|
||||
@@ -53,6 +62,105 @@ const latestPov = findLatestNode(
|
||||
assert.equal(latestObjective?.id, objectiveNode.id);
|
||||
assert.equal(latestPov?.id, povNode.id);
|
||||
|
||||
const normalizedScope = {
|
||||
layer: "pov",
|
||||
ownerType: "character",
|
||||
ownerId: "艾琳",
|
||||
ownerName: "艾琳",
|
||||
regionPrimary: "钟楼",
|
||||
regionPath: ["钟楼", "塔顶"],
|
||||
regionSecondary: ["旧城区"],
|
||||
};
|
||||
assert.equal(
|
||||
normalizeMemoryScope(normalizedScope),
|
||||
normalizedScope,
|
||||
"已规范的 scope 对象应直接复用",
|
||||
);
|
||||
|
||||
const malformedSecondaryScope = normalizeMemoryScope({
|
||||
layer: "objective",
|
||||
regionPrimary: "王都/钟楼",
|
||||
regionSecondary: "旧城区, 集市 / 下水道 / 钟楼",
|
||||
});
|
||||
assert.equal(malformedSecondaryScope.regionPrimary, "钟楼");
|
||||
assert.deepEqual(malformedSecondaryScope.regionPath, ["王都", "钟楼"]);
|
||||
assert.deepEqual(malformedSecondaryScope.regionSecondary, [
|
||||
"旧城区",
|
||||
"集市",
|
||||
"下水道",
|
||||
]);
|
||||
assert.deepEqual(getScopeRegionTokens(malformedSecondaryScope), [
|
||||
"钟楼",
|
||||
"王都",
|
||||
"旧城区",
|
||||
"集市",
|
||||
"下水道",
|
||||
]);
|
||||
assert.match(buildRegionLine(malformedSecondaryScope), /次级地区/);
|
||||
|
||||
const accessorBackedScope = {};
|
||||
Object.defineProperty(accessorBackedScope, "layer", {
|
||||
get() {
|
||||
return "objective";
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
Object.defineProperty(accessorBackedScope, "regionPrimary", {
|
||||
get() {
|
||||
return "钟楼";
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
Object.defineProperty(accessorBackedScope, "regionPath", {
|
||||
get() {
|
||||
return "王都 > 钟楼";
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
Object.defineProperty(accessorBackedScope, "regionSecondary", {
|
||||
get() {
|
||||
return { label: "旧城区 / 集市" };
|
||||
},
|
||||
enumerable: true,
|
||||
});
|
||||
const normalizedAccessorScope = normalizeMemoryScope(accessorBackedScope);
|
||||
assert.notEqual(
|
||||
normalizedAccessorScope,
|
||||
accessorBackedScope,
|
||||
"带 accessor 的 scope 不应复用原对象",
|
||||
);
|
||||
assert.deepEqual(normalizedAccessorScope.regionPath, ["王都", "钟楼"]);
|
||||
assert.deepEqual(normalizedAccessorScope.regionSecondary, ["旧城区", "集市"]);
|
||||
|
||||
const normalizedStoryTime = {
|
||||
segmentId: "tl-1",
|
||||
label: "第二天清晨",
|
||||
tense: "ongoing",
|
||||
relation: "same",
|
||||
anchorLabel: "昨夜",
|
||||
confidence: "high",
|
||||
source: "derived",
|
||||
};
|
||||
assert.equal(
|
||||
normalizeStoryTime(normalizedStoryTime),
|
||||
normalizedStoryTime,
|
||||
"已规范的 storyTime 对象应直接复用",
|
||||
);
|
||||
|
||||
const normalizedStoryTimeSpan = {
|
||||
startSegmentId: "tl-0",
|
||||
endSegmentId: "tl-1",
|
||||
startLabel: "昨夜",
|
||||
endLabel: "第二天清晨",
|
||||
mixed: false,
|
||||
source: "derived",
|
||||
};
|
||||
assert.equal(
|
||||
normalizeStoryTimeSpan(normalizedStoryTimeSpan),
|
||||
normalizedStoryTimeSpan,
|
||||
"已规范的 storyTimeSpan 对象应直接复用",
|
||||
);
|
||||
|
||||
const legacyGraph = deserializeGraph({
|
||||
version: 6,
|
||||
lastProcessedSeq: 0,
|
||||
|
||||
@@ -155,7 +155,16 @@ try {
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(JSON.stringify(graph), graphBefore, "shared ranking should be side-effect-free");
|
||||
const stripDiagnosticTimings = (json) => {
|
||||
const obj = JSON.parse(json);
|
||||
if (obj?.vectorIndexState) delete obj.vectorIndexState.lastSearchTimings;
|
||||
return JSON.stringify(obj);
|
||||
};
|
||||
assert.equal(
|
||||
stripDiagnosticTimings(JSON.stringify(graph)),
|
||||
stripDiagnosticTimings(graphBefore),
|
||||
"shared ranking should be side-effect-free (ignoring diagnostic timings)",
|
||||
);
|
||||
assert.equal(first.scoredNodes[0]?.nodeId, confession.id);
|
||||
assert.equal(second.scoredNodes[0]?.nodeId, confession.id);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
LEGACY_PLANNER_SYSTEM_PROMPT,
|
||||
PLANNER_ASSISTANT_SEED,
|
||||
} from "../ena-planner/ena-planner-presets.js";
|
||||
import {
|
||||
createDefaultTaskProfiles,
|
||||
ensureTaskProfiles,
|
||||
@@ -23,6 +27,7 @@ assert.equal(migrated.taskProfilesVersion, 3);
|
||||
assert.ok(migrated.taskProfiles);
|
||||
assert.ok(migrated.taskProfiles.extract);
|
||||
assert.ok(migrated.taskProfiles.recall);
|
||||
assert.ok(migrated.taskProfiles.planner);
|
||||
|
||||
const extractProfile = getActiveTaskProfile(
|
||||
{
|
||||
@@ -34,22 +39,24 @@ const extractProfile = getActiveTaskProfile(
|
||||
assert.equal(extractProfile.taskType, "extract");
|
||||
assert.equal(extractProfile.id, "default");
|
||||
assert.ok(Array.isArray(extractProfile.blocks));
|
||||
assert.equal(extractProfile.blocks.length, 14);
|
||||
assert.equal(extractProfile.blocks.length, 16);
|
||||
assert.deepEqual(
|
||||
extractProfile.blocks.map((block) => block.name),
|
||||
[
|
||||
"抬头",
|
||||
"角色定义",
|
||||
"身份确认",
|
||||
"角色描述",
|
||||
"用户设定",
|
||||
"世界书前块",
|
||||
"世界书后块",
|
||||
"最近消息",
|
||||
"图统计",
|
||||
"Schema",
|
||||
"当前范围",
|
||||
"活跃总结",
|
||||
"故事时间",
|
||||
"当前范围",
|
||||
"最近消息",
|
||||
"信息确认",
|
||||
"输出格式",
|
||||
"行为规则",
|
||||
],
|
||||
@@ -57,6 +64,7 @@ assert.deepEqual(
|
||||
assert.deepEqual(
|
||||
extractProfile.blocks.map((block) => block.type),
|
||||
[
|
||||
"custom",
|
||||
"custom",
|
||||
"custom",
|
||||
"builtin",
|
||||
@@ -71,6 +79,7 @@ assert.deepEqual(
|
||||
"builtin",
|
||||
"custom",
|
||||
"custom",
|
||||
"custom",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -78,6 +87,7 @@ assert.deepEqual(
|
||||
[
|
||||
"system",
|
||||
"system",
|
||||
"assistant",
|
||||
"system",
|
||||
"system",
|
||||
"system",
|
||||
@@ -88,6 +98,7 @@ assert.deepEqual(
|
||||
"system",
|
||||
"system",
|
||||
"system",
|
||||
"assistant",
|
||||
"user",
|
||||
"user",
|
||||
],
|
||||
@@ -112,15 +123,17 @@ assert.deepEqual(
|
||||
[
|
||||
"default-heading",
|
||||
"default-role",
|
||||
"default-identity-ack",
|
||||
"charDescription",
|
||||
"userPersona",
|
||||
"worldInfoBefore",
|
||||
"worldInfoAfter",
|
||||
"graphStats",
|
||||
"sceneOwnerCandidates",
|
||||
"candidateNodes",
|
||||
"recentMessages",
|
||||
"userMessage",
|
||||
"candidateNodes",
|
||||
"sceneOwnerCandidates",
|
||||
"graphStats",
|
||||
"default-info-ack",
|
||||
"default-format",
|
||||
"default-rules",
|
||||
],
|
||||
@@ -130,19 +143,259 @@ assert.deepEqual(
|
||||
[
|
||||
"default-heading",
|
||||
"default-role",
|
||||
"default-identity-ack",
|
||||
"charDescription",
|
||||
"userPersona",
|
||||
"worldInfoBefore",
|
||||
"worldInfoAfter",
|
||||
"recentMessages",
|
||||
"graphStats",
|
||||
"candidateText",
|
||||
"currentRange",
|
||||
"graphStats",
|
||||
"recentMessages",
|
||||
"default-info-ack",
|
||||
"default-format",
|
||||
"default-rules",
|
||||
],
|
||||
);
|
||||
assert.ok(defaults.summary_rollup.profiles.length > 0);
|
||||
assert.ok(defaults.planner.profiles.length > 0);
|
||||
assert.deepEqual(
|
||||
defaults.planner.profiles[0].blocks.map((block) => block.sourceKey || block.id),
|
||||
[
|
||||
"planner-default-heading",
|
||||
"planner-default-role",
|
||||
"planner-default-identity-ack",
|
||||
"plannerCharacterCard",
|
||||
"plannerWorldbook",
|
||||
"plannerMemory",
|
||||
"plannerPreviousPlots",
|
||||
"plannerRecentChat",
|
||||
"plannerUserInput",
|
||||
"planner-default-info-ack",
|
||||
"planner-default-format",
|
||||
"planner-default-rules",
|
||||
"planner-default-assistant-seed",
|
||||
],
|
||||
);
|
||||
assert.equal(defaults.planner.profiles[0].generation.stream, true);
|
||||
assert.equal(defaults.planner.profiles[0].generation.temperature, 1);
|
||||
|
||||
const currentDefaultPlanner = defaults.planner.profiles[0];
|
||||
const cloneValue = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
function buildLegacyPlannerDefaultLikeBlocks() {
|
||||
return [
|
||||
{
|
||||
id: "planner-legacy-default-system",
|
||||
name: "Ena Planner System",
|
||||
type: "custom",
|
||||
enabled: true,
|
||||
role: "system",
|
||||
sourceKey: "",
|
||||
sourceField: "",
|
||||
content: LEGACY_PLANNER_SYSTEM_PROMPT,
|
||||
injectionMode: "relative",
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: "planner-legacy-default-char",
|
||||
name: "角色卡",
|
||||
type: "builtin",
|
||||
enabled: true,
|
||||
role: "system",
|
||||
sourceKey: "plannerCharacterCard",
|
||||
sourceField: "",
|
||||
content: "",
|
||||
injectionMode: "relative",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: "planner-legacy-default-worldbook",
|
||||
name: "世界书",
|
||||
type: "builtin",
|
||||
enabled: true,
|
||||
role: "system",
|
||||
sourceKey: "plannerWorldbook",
|
||||
sourceField: "",
|
||||
content: "",
|
||||
injectionMode: "relative",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: "planner-legacy-default-recent-chat",
|
||||
name: "最近聊天",
|
||||
type: "builtin",
|
||||
enabled: true,
|
||||
role: "system",
|
||||
sourceKey: "plannerRecentChat",
|
||||
sourceField: "",
|
||||
content: "",
|
||||
injectionMode: "relative",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: "planner-legacy-default-memory",
|
||||
name: "BME 记忆",
|
||||
type: "builtin",
|
||||
enabled: true,
|
||||
role: "system",
|
||||
sourceKey: "plannerMemory",
|
||||
sourceField: "",
|
||||
content: "",
|
||||
injectionMode: "relative",
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
id: "planner-legacy-default-previous-plots",
|
||||
name: "历史 plot",
|
||||
type: "builtin",
|
||||
enabled: true,
|
||||
role: "system",
|
||||
sourceKey: "plannerPreviousPlots",
|
||||
sourceField: "",
|
||||
content: "",
|
||||
injectionMode: "relative",
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
id: "planner-legacy-default-user-input",
|
||||
name: "玩家输入",
|
||||
type: "builtin",
|
||||
enabled: true,
|
||||
role: "user",
|
||||
sourceKey: "plannerUserInput",
|
||||
sourceField: "",
|
||||
content: "",
|
||||
injectionMode: "relative",
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
id: "planner-legacy-default-seed",
|
||||
name: "Assistant Seed",
|
||||
type: "custom",
|
||||
enabled: true,
|
||||
role: "assistant",
|
||||
sourceKey: "",
|
||||
sourceField: "",
|
||||
content: PLANNER_ASSISTANT_SEED,
|
||||
injectionMode: "relative",
|
||||
order: 7,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createLegacyPlannerDefaultLikeProfile(overrides = {}) {
|
||||
return {
|
||||
id: "planner-legacy-default-like",
|
||||
taskType: "planner",
|
||||
builtin: false,
|
||||
name: "ENA 当前配置",
|
||||
promptMode: "block-based",
|
||||
enabled: true,
|
||||
updatedAt: "2026-04-23T00:00:00.000Z",
|
||||
blocks: buildLegacyPlannerDefaultLikeBlocks(),
|
||||
generation: cloneValue(currentDefaultPlanner.generation),
|
||||
metadata: {
|
||||
migratedFromLegacy: true,
|
||||
enaLegacySource: "legacy-working-copy",
|
||||
},
|
||||
...overrides,
|
||||
blocks: Array.isArray(overrides.blocks)
|
||||
? overrides.blocks
|
||||
: buildLegacyPlannerDefaultLikeBlocks(),
|
||||
generation: {
|
||||
...cloneValue(currentDefaultPlanner.generation),
|
||||
...(overrides.generation || {}),
|
||||
},
|
||||
metadata: {
|
||||
migratedFromLegacy: true,
|
||||
enaLegacySource: "legacy-working-copy",
|
||||
...(overrides.metadata || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const legacyPlannerDefaultLikeProfile = createLegacyPlannerDefaultLikeProfile();
|
||||
const alignedLegacyPlannerDefaults = ensureTaskProfiles({
|
||||
taskProfilesVersion: 3,
|
||||
taskProfiles: {
|
||||
planner: {
|
||||
activeProfileId: legacyPlannerDefaultLikeProfile.id,
|
||||
profiles: [cloneValue(currentDefaultPlanner), legacyPlannerDefaultLikeProfile],
|
||||
},
|
||||
},
|
||||
});
|
||||
const alignedLegacyPlannerProfile = alignedLegacyPlannerDefaults.planner.profiles.find(
|
||||
(profile) => profile.id === legacyPlannerDefaultLikeProfile.id,
|
||||
);
|
||||
assert.equal(alignedLegacyPlannerDefaults.planner.activeProfileId, "default");
|
||||
assert.deepEqual(
|
||||
alignedLegacyPlannerProfile.blocks.map((block) => block.sourceKey || block.id),
|
||||
currentDefaultPlanner.blocks.map((block) => block.sourceKey || block.id),
|
||||
);
|
||||
assert.equal(alignedLegacyPlannerProfile.metadata.plannerLegacyDefaultAligned, true);
|
||||
|
||||
const legacyPlannerCustomGenerationProfile = createLegacyPlannerDefaultLikeProfile({
|
||||
id: "planner-legacy-custom-generation",
|
||||
generation: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
const alignedLegacyPlannerCustomGeneration = ensureTaskProfiles({
|
||||
taskProfilesVersion: 3,
|
||||
taskProfiles: {
|
||||
planner: {
|
||||
activeProfileId: legacyPlannerCustomGenerationProfile.id,
|
||||
profiles: [
|
||||
cloneValue(currentDefaultPlanner),
|
||||
legacyPlannerCustomGenerationProfile,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const alignedLegacyPlannerCustomGenerationProfile =
|
||||
alignedLegacyPlannerCustomGeneration.planner.profiles.find(
|
||||
(profile) => profile.id === legacyPlannerCustomGenerationProfile.id,
|
||||
);
|
||||
assert.equal(
|
||||
alignedLegacyPlannerCustomGeneration.planner.activeProfileId,
|
||||
legacyPlannerCustomGenerationProfile.id,
|
||||
);
|
||||
assert.deepEqual(
|
||||
alignedLegacyPlannerCustomGenerationProfile.blocks.map(
|
||||
(block) => block.sourceKey || block.id,
|
||||
),
|
||||
currentDefaultPlanner.blocks.map((block) => block.sourceKey || block.id),
|
||||
);
|
||||
assert.equal(alignedLegacyPlannerCustomGenerationProfile.generation.temperature, 0.7);
|
||||
|
||||
const customizedLegacyPlannerBlocks = buildLegacyPlannerDefaultLikeBlocks();
|
||||
customizedLegacyPlannerBlocks[0].content = `${customizedLegacyPlannerBlocks[0].content}\n\n自定义补充`;
|
||||
const customizedLegacyPlannerProfile = createLegacyPlannerDefaultLikeProfile({
|
||||
id: "planner-legacy-customized",
|
||||
blocks: customizedLegacyPlannerBlocks,
|
||||
});
|
||||
const preservedCustomizedLegacyPlanner = ensureTaskProfiles({
|
||||
taskProfilesVersion: 3,
|
||||
taskProfiles: {
|
||||
planner: {
|
||||
activeProfileId: customizedLegacyPlannerProfile.id,
|
||||
profiles: [cloneValue(currentDefaultPlanner), customizedLegacyPlannerProfile],
|
||||
},
|
||||
},
|
||||
});
|
||||
const preservedCustomizedLegacyPlannerProfile =
|
||||
preservedCustomizedLegacyPlanner.planner.profiles.find(
|
||||
(profile) => profile.id === customizedLegacyPlannerProfile.id,
|
||||
);
|
||||
assert.equal(
|
||||
preservedCustomizedLegacyPlanner.planner.activeProfileId,
|
||||
customizedLegacyPlannerProfile.id,
|
||||
);
|
||||
assert.match(
|
||||
preservedCustomizedLegacyPlannerProfile.blocks[0].content,
|
||||
/自定义补充/,
|
||||
);
|
||||
|
||||
const upgradedLegacyDefault = getActiveTaskProfile(
|
||||
{
|
||||
@@ -220,16 +473,34 @@ const upgradedLegacyDefault = getActiveTaskProfile(
|
||||
},
|
||||
"extract",
|
||||
);
|
||||
assert.equal(upgradedLegacyDefault.blocks.length, 14);
|
||||
assert.equal(upgradedLegacyDefault.blocks.length, 16);
|
||||
assert.equal(upgradedLegacyDefault.blocks[0].name, "抬头");
|
||||
assert.match(upgradedLegacyDefault.blocks[0].content, /虚拟的世界/);
|
||||
assert.equal(upgradedLegacyDefault.blocks[0].role, "system");
|
||||
assert.equal(upgradedLegacyDefault.blocks[0].injectionMode, "relative");
|
||||
assert.equal(upgradedLegacyDefault.blocks[1].content, "保留我自己的角色定义");
|
||||
assert.equal(upgradedLegacyDefault.blocks[12].content, "保留我自己的输出格式");
|
||||
assert.equal(upgradedLegacyDefault.blocks[13].content, "保留我自己的行为规则");
|
||||
assert.equal(upgradedLegacyDefault.blocks[12].role, "user");
|
||||
assert.equal(upgradedLegacyDefault.blocks[13].role, "user");
|
||||
const upgradedIdentityAck = upgradedLegacyDefault.blocks.find(
|
||||
(block) => block.id === "default-identity-ack",
|
||||
);
|
||||
assert.ok(
|
||||
upgradedIdentityAck,
|
||||
"legacy upgrade should backfill default-identity-ack block",
|
||||
);
|
||||
assert.equal(upgradedIdentityAck.role, "assistant");
|
||||
const upgradedInfoAck = upgradedLegacyDefault.blocks.find(
|
||||
(block) => block.id === "default-info-ack",
|
||||
);
|
||||
assert.ok(
|
||||
upgradedInfoAck,
|
||||
"legacy upgrade should backfill default-info-ack block",
|
||||
);
|
||||
assert.equal(upgradedInfoAck.role, "assistant");
|
||||
assert.equal(upgradedLegacyDefault.blocks[14].id, "default-format");
|
||||
assert.equal(upgradedLegacyDefault.blocks[15].id, "default-rules");
|
||||
assert.equal(upgradedLegacyDefault.blocks[14].content, "保留我自己的输出格式");
|
||||
assert.equal(upgradedLegacyDefault.blocks[15].content, "保留我自己的行为规则");
|
||||
assert.equal(upgradedLegacyDefault.blocks[14].role, "user");
|
||||
assert.equal(upgradedLegacyDefault.blocks[15].role, "user");
|
||||
|
||||
const currentDefaults = createDefaultTaskProfiles();
|
||||
const currentDefaultExtract = currentDefaults.extract.profiles[0];
|
||||
@@ -389,15 +660,33 @@ assert.equal(
|
||||
|
||||
assert.deepEqual(
|
||||
upgradedLegacyDefault.blocks
|
||||
.slice(6, 10)
|
||||
.slice(7, 13)
|
||||
.map((block) => block.sourceKey),
|
||||
["recentMessages", "graphStats", "schema", "currentRange"],
|
||||
[
|
||||
"graphStats",
|
||||
"schema",
|
||||
"activeSummaries",
|
||||
"storyTimeContext",
|
||||
"currentRange",
|
||||
"recentMessages",
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
upgradedLegacyDefault.blocks
|
||||
.slice(0, 12)
|
||||
.slice(0, 2)
|
||||
.every((block) => block.role === "system"),
|
||||
"heading / role 头部块应保持 system 角色",
|
||||
);
|
||||
assert.equal(upgradedLegacyDefault.blocks[2].id, "default-identity-ack");
|
||||
assert.equal(upgradedLegacyDefault.blocks[2].role, "assistant");
|
||||
assert.ok(
|
||||
upgradedLegacyDefault.blocks
|
||||
.slice(3, 13)
|
||||
.every((block) => block.role === "system"),
|
||||
"参考材料与本轮输入块应为 system 角色",
|
||||
);
|
||||
assert.equal(upgradedLegacyDefault.blocks[13].id, "default-info-ack");
|
||||
assert.equal(upgradedLegacyDefault.blocks[13].role, "assistant");
|
||||
|
||||
const legacyRegexSettings = {
|
||||
taskProfilesVersion: 3,
|
||||
|
||||
@@ -53,7 +53,7 @@ const activeProfile = getActiveTaskProfile(
|
||||
"extract",
|
||||
);
|
||||
assert.equal(activeProfile.name, "激进提取");
|
||||
assert.equal(activeProfile.blocks.length, 16);
|
||||
assert.equal(activeProfile.blocks.length, 18);
|
||||
const builtinBlock = activeProfile.blocks.find(
|
||||
(block) => block.type === "builtin" && block.sourceKey === "userMessage",
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user