import assert from "node:assert/strict"; import { installResolveHooks, toDataModuleUrl, } from "./helpers/register-hooks-compat.mjs"; const extensionsShimSource = [ "export const extension_settings = {};", "export function getContext() {", " return globalThis.__stBmeTestContext || {", " chat: [],", " chatMetadata: {},", " extensionSettings: {},", " powerUserSettings: {},", " characters: {},", " characterId: null,", " name1: '玩家',", " name2: '艾琳',", " chatId: 'test-chat',", " };", "}", ].join("\n"); const scriptShimSource = [ "export function getRequestHeaders() {", " return {};", "}", "export function substituteParamsExtended(value) {", " return String(value ?? '');", "}", ].join("\n"); const openAiShimSource = [ "export const chat_completion_sources = {};", "export async function sendOpenAIRequest() {", " throw new Error('sendOpenAIRequest should not be called in extractor-split-pipeline test');", "}", ].join("\n"); installResolveHooks([ { specifiers: [ "../../../extensions.js", "../../../../extensions.js", "../../../../../extensions.js", ], url: toDataModuleUrl(extensionsShimSource), }, { specifiers: [ "../../../../script.js", "../../../../../script.js", ], url: toDataModuleUrl(scriptShimSource), }, { specifiers: [ "../../../../openai.js", "../../../../../openai.js", ], url: toDataModuleUrl(openAiShimSource), }, ]); const { createEmptyGraph, createNode, addNode } = await import("../graph/graph.js"); const { DEFAULT_NODE_SCHEMA } = await import("../graph/schema.js"); const { extractMemories } = await import("../maintenance/extractor.js"); const { defaultSettings } = await import("../runtime/settings-defaults.js"); function setTestOverrides(overrides = {}) { globalThis.__stBmeTestOverrides = overrides; return () => { delete globalThis.__stBmeTestOverrides; }; } globalThis.__stBmeTestContext = { chat: [], chatMetadata: {}, extensionSettings: {}, powerUserSettings: {}, characters: {}, characterId: null, name1: "玩家", name2: "艾琳", chatId: "test-chat", }; function createGraphWithCharacter() { const graph = createEmptyGraph(); addNode( graph, createNode({ type: "character", fields: { name: "艾琳" }, seq: 1, }), ); return graph; } const baseExtractParams = { messages: [ { seq: 20, role: "user", content: "钟楼里传来第二次钟声。", name: "玩家", speaker: "玩家" }, { seq: 21, role: "assistant", content: "艾琳记下钟声,怀疑暗道就在附近。", name: "艾琳", speaker: "艾琳" }, ], startSeq: 20, endSeq: 21, schema: DEFAULT_NODE_SCHEMA, embeddingConfig: null, }; function objectivePayload() { return { operations: [ { action: "create", type: "event", ref: "evt-clock", fields: { title: "钟楼钟声", summary: "钟楼传来第二次钟声,暗示暗道线索仍在附近。", participants: "玩家,艾琳", status: "ongoing", }, scope: { layer: "objective" }, }, ], cognitionUpdates: [ { ownerType: "character", ownerName: "艾琳", knownRefs: ["evt-clock"], }, ], regionUpdates: {}, }; } function subjectivePayload() { return { operations: [ { action: "create", type: "pov_memory", fields: { summary: "艾琳把第二次钟声记成暗道仍在呼唤她的证据。", belief: "暗道就在钟楼附近", emotion: "警觉", certainty: "unsure", about: "evt-clock", }, scope: { layer: "pov", ownerType: "character", ownerName: "艾琳", ownerId: "艾琳", }, }, ], cognitionUpdates: [ { ownerType: "character", ownerName: "艾琳", knownRefs: ["evt-clock"], }, ], regionUpdates: {}, }; } function activeNodes(graph, type) { return graph.nodes.filter((node) => node.type === type && node.archived !== true); } function hasActiveEdgeBetween(graph, leftId, rightId) { return graph.edges.some((edge) => { if (edge.invalidAt || edge.expiredAt) return false; return ( (edge.fromId === leftId && edge.toId === rightId) || (edge.fromId === rightId && edge.toId === leftId) ); }); } function characterKnowledgeEntries(graph) { return Object.values(graph.knowledgeState?.owners || {}).filter( (entry) => String(entry?.ownerType || "") === "character" && String(entry?.ownerName || "") === "艾琳", ); } async function captureTaskTypesForExtract(settings, options = {}) { const graph = createGraphWithCharacter(); const capturedTaskTypes = []; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload = {}) { capturedTaskTypes.push(payload.taskType); if (payload.taskType === "extract_objective") return objectivePayload(); if (payload.taskType === "extract_subjective") return subjectivePayload(); if (payload.taskType === "extract") return { operations: [], cognitionUpdates: [], regionUpdates: {} }; return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const params = { graph, ...baseExtractParams, }; if (options.includeSettings !== false) { params.settings = settings; } const result = await extractMemories(params); return { graph, result, capturedTaskTypes }; } finally { restore(); } } function cloneJson(value) { return JSON.parse(JSON.stringify(value)); } function createCustomizedLegacyExtractProfileSettings() { const taskProfiles = cloneJson(defaultSettings.taskProfiles); const baseProfile = taskProfiles.extract.profiles[0]; const customProfile = { ...baseProfile, id: "custom-legacy-extract-profile", name: "Custom legacy extract profile", builtin: false, blocks: (Array.isArray(baseProfile.blocks) ? baseProfile.blocks : []).map((block, index) => index === 0 ? { ...block, content: `${String(block.content || "")}\nCUSTOM_LEGACY_EXTRACT_SENTINEL` } : { ...block }, ), }; taskProfiles.extract = { activeProfileId: customProfile.id, profiles: [baseProfile, customProfile], }; return { ...defaultSettings, extractPipelineVersion: "split-v1", taskProfiles, }; } function createDefaultExtractProfileSettings(mutator) { const taskProfiles = cloneJson(defaultSettings.taskProfiles); const extractProfiles = taskProfiles.extract.profiles || []; const defaultProfile = extractProfiles.find((profile) => profile.id === "default") || extractProfiles[0]; mutator?.(defaultProfile, taskProfiles.extract); return { ...defaultSettings, extractPipelineVersion: "split-v1", taskProfiles, }; } // Phase 4 default switch: omitting settings should use the split pipeline by default. { const { result, capturedTaskTypes } = await captureTaskTypesForExtract(undefined, { includeSettings: false, }); assert.equal(result.success, true); assert.deepEqual( capturedTaskTypes, ["extract_objective", "extract_subjective"], "extractMemories without explicit settings should default to split objective+subjective extraction", ); } // Phase 4 default switch: the default settings object should request split-v1. { const { result, capturedTaskTypes } = await captureTaskTypesForExtract({ ...defaultSettings, }); assert.equal(result.success, true); assert.equal(defaultSettings.extractPipelineVersion, "split-v1"); assert.deepEqual( capturedTaskTypes, ["extract_objective", "extract_subjective"], "defaultSettings should call split objective+subjective extraction", ); } // split-v1 calls objective then subjective, merges both stage outputs, and commits once. { const graph = createGraphWithCharacter(); const capturedTaskTypes = []; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload = {}) { capturedTaskTypes.push(payload.taskType); if (payload.taskType === "extract_objective") return objectivePayload(); if (payload.taskType === "extract_subjective") return subjectivePayload(); return { operations: [], cognitionUpdates: [], regionUpdates: {} }; }, }, }); try { const result = await extractMemories({ graph, ...baseExtractParams, settings: { extractPipelineVersion: "split-v1" }, }); assert.deepEqual( capturedTaskTypes, ["extract_objective", "extract_subjective"], "split-v1 should call the LLM once for objective extraction, then once for subjective extraction", ); assert.equal(result.success, true); assert.equal(result.newNodes, 2, "objective event and subjective POV memory should be committed together"); const [eventNode] = activeNodes(graph, "event"); const [povNode] = activeNodes(graph, "pov_memory"); assert.ok(eventNode, "objective event operation should be committed"); assert.ok(povNode, "subjective pov_memory operation should be committed"); assert.equal(povNode.scope?.ownerType, "character"); assert.equal(povNode.scope?.ownerName, "艾琳"); assert.equal(graph.lastProcessedSeq, 21); assert.ok( hasActiveEdgeBetween(graph, eventNode.id, povNode.id), "merged split stages should be committed as one batch so default batch edges see both nodes", ); const knowledgeEntry = characterKnowledgeEntries(graph).find((entry) => Array.isArray(entry.knownNodeIds) && entry.knownNodeIds.includes(eventNode.id), ); assert.ok( knowledgeEntry, "subjective cognitionUpdates should apply through the merged ref map", ); } finally { restore(); } } // Invalid subjective output fails the split extraction before any objective-only commit mutates the graph. { const graph = createGraphWithCharacter(); const initialNodeCount = graph.nodes.length; const initialEdgeCount = graph.edges.length; const capturedTaskTypes = []; const restore = setTestOverrides({ llm: { async callLLMForJSON(payload = {}) { capturedTaskTypes.push(payload.taskType); if (payload.taskType === "extract_objective") return objectivePayload(); if (payload.taskType === "extract_subjective") return { thought: "missing operations" }; return { thought: "legacy path should not be used for split-v1" }; }, }, }); try { const result = await extractMemories({ graph, ...baseExtractParams, settings: { extractPipelineVersion: "split-v1" }, }); assert.deepEqual( capturedTaskTypes, ["extract_objective", "extract_subjective"], "split-v1 should validate both objective and subjective payloads before commit", ); assert.equal(result.success, false); assert.equal(graph.nodes.length, initialNodeCount, "invalid subjective payload should not commit objective nodes"); assert.equal(graph.edges.length, initialEdgeCount, "invalid subjective payload should not create edges"); assert.equal(graph.lastProcessedSeq ?? -1, -1, "invalid split extraction should not advance extraction progress"); } finally { restore(); } } // Legacy guard: a non-empty legacy extractPrompt should force the single extract taskType path. { const { result, capturedTaskTypes } = await captureTaskTypesForExtract({ ...defaultSettings, extractPipelineVersion: "split-v1", extractPrompt: "CUSTOM LEGACY EXTRACT PROMPT", }); assert.equal(result.success, true); assert.deepEqual( capturedTaskTypes, ["extract"], "non-empty extractPrompt should guard back to legacy taskType extract", ); } // Legacy guard: an active customized legacy extract task profile should force the single extract path. { const { result, capturedTaskTypes } = await captureTaskTypesForExtract( createCustomizedLegacyExtractProfileSettings(), ); assert.equal(result.success, true); assert.deepEqual( capturedTaskTypes, ["extract"], "customized active taskProfiles.extract profile should guard back to legacy taskType extract", ); } // Legacy guard: an explicit legacy override should always keep the single extract path. { const { result, capturedTaskTypes } = await captureTaskTypesForExtract({ ...defaultSettings, extractPipelineVersion: "legacy-single", }); assert.equal(result.success, true); assert.deepEqual(capturedTaskTypes, ["extract"]); } // Legacy guard: migrated legacy default-looking profiles are conservative legacy. { const { result, capturedTaskTypes } = await captureTaskTypesForExtract( createDefaultExtractProfileSettings((profile) => { profile.metadata = { ...(profile.metadata || {}), migratedFromLegacy: true, }; }), ); assert.equal(result.success, true); assert.deepEqual(capturedTaskTypes, ["extract"]); } // Legacy guard: stale default profile metadata is conservative legacy. { const { result, capturedTaskTypes } = await captureTaskTypesForExtract( createDefaultExtractProfileSettings((profile) => { profile.metadata = { ...(profile.metadata || {}), defaultTemplateFingerprint: "stale-fingerprint", }; }), ); assert.equal(result.success, true); assert.deepEqual(capturedTaskTypes, ["extract"]); } // Legacy guard: modified default profile content is conservative legacy even if id/builtin remain default. { const { result, capturedTaskTypes } = await captureTaskTypesForExtract( createDefaultExtractProfileSettings((profile) => { profile.blocks = (profile.blocks || []).map((block, index) => index === 0 ? { ...block, content: `${String(block.content || "")} CUSTOM_DEFAULT_PROFILE_SENTINEL` } : { ...block }, ); }), ); assert.equal(result.success, true); assert.deepEqual(capturedTaskTypes, ["extract"]); } console.log("extractor-split-pipeline tests passed");