diff --git a/src/core/hooks/__tests__/stable-wrapper.test.ts b/src/core/hooks/__tests__/stable-wrapper.test.ts new file mode 100644 index 00000000..46d3bc0f --- /dev/null +++ b/src/core/hooks/__tests__/stable-wrapper.test.ts @@ -0,0 +1,103 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { parseConfig } from "../../../config.js"; +import { performAutoRecall } from "../auto-recall.js"; +import type { IMemoryStore, L1FtsResult, StoreCapabilities } from "../../store/types.js"; +import type { Logger } from "../../types.js"; + +const tempDirs: string[] = []; + +const logger: Logger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +}; + +const capabilities: StoreCapabilities = { + vectorSearch: false, + ftsSearch: true, + nativeHybridSearch: false, + sparseVectors: false, +}; + +function makeStore(results: L1FtsResult[]): IMemoryStore { + return { + getCapabilities: () => capabilities, + isFtsAvailable: () => true, + searchL1Fts: () => results, + searchL1Vector: () => [], + } as unknown as IMemoryStore; +} + +async function makeDataDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "tdai-stable-wrapper-")); + tempDirs.push(dir); + return dir; +} + +async function recall(results: L1FtsResult[], userText = "How is my prompt cache?") { + return performAutoRecall({ + userText, + actorId: "agent", + sessionKey: "session", + cfg: parseConfig({ + recall: { + strategy: "keyword", + scoreThreshold: 0, + }, + }), + pluginDataDir: await makeDataDir(), + logger, + vectorStore: makeStore(results), + }); +} + +function ftsResult(content: string): L1FtsResult { + return { + record_id: `record-${content}`, + content, + type: "instruction", + priority: 1, + scene_name: "", + score: 1, + timestamp_str: "", + timestamp_start: "", + timestamp_end: "", + session_key: "session", + session_id: "session-id", + metadata_json: "{}", + }; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("performAutoRecall stable wrapper", () => { + it("keeps a relevant-memories wrapper when L1 search runs with zero hits", async () => { + const result = await recall([]); + + expect(result?.prependContext).toContain(""); + expect(result?.prependContext).toContain("未召回相关记忆"); + expect(result?.prependContext).toContain(""); + expect(result?.appendSystemContext).toContain(""); + expect(result?.recalledL1Memories).toEqual([]); + }); + + it("keeps real recalled memories instead of the empty placeholder", async () => { + const result = await recall([ftsResult("User prefers concise answers")]); + + expect(result?.prependContext).toContain(""); + expect(result?.prependContext).toContain("User prefers concise answers"); + expect(result?.prependContext).not.toContain("未召回相关记忆"); + }); + + it("does not inject the empty wrapper when recall search is skipped", async () => { + const result = await recall([], ""); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/core/hooks/auto-recall.ts b/src/core/hooks/auto-recall.ts index 23c9237c..24e9f0cd 100644 --- a/src/core/hooks/auto-recall.ts +++ b/src/core/hooks/auto-recall.ts @@ -27,6 +27,7 @@ const TAG = "[memory-tdai] [recall]"; const RECALL_TRUNCATION_SUFFIX = "…(已截断;可用 tdai_memory_search 或 tdai_conversation_search 查看详情)"; const MIN_TRUNCATED_RECALL_LINE_CHARS = 40; const RECALL_LINE_SEPARATOR = "\n"; +const NO_RELEVANT_MEMORIES_PLACEHOLDER = "(本次对话未召回相关记忆)"; /** * Memory tools usage guide — injected at the end of memory context so the @@ -115,12 +116,14 @@ async function performAutoRecallInner(params: { // Search relevant memories (L1 layer) — skip only when userText is empty/undefined const tSearchStart = performance.now(); let memoryLines: string[] = []; + let memorySearchAttempted = false; let effectiveStrategy = "skipped"; let recalledL1Memories: RecalledMemory[] = []; let searchTiming: SearchTiming = { ftsMs: 0, embeddingMs: 0, ftsHits: 0, embeddingHits: 0 }; if (!userText || userText.length === 0) { logger?.debug?.(`${TAG} User text empty/undefined, skipping memory search (persona/scene still injected)`); } else { + memorySearchAttempted = true; effectiveStrategy = cfg.recall.strategy ?? "hybrid"; const searchResult = await searchMemories(userText, pluginDataDir, cfg, logger, effectiveStrategy as "keyword" | "embedding" | "hybrid", vectorStore, embeddingService); memoryLines = searchResult.lines; @@ -169,7 +172,7 @@ async function performAutoRecallInner(params: { } const tSceneEnd = performance.now(); - if (memoryLines.length === 0 && !personaContent && !sceneNavigation) { + if (!memorySearchAttempted && memoryLines.length === 0 && !personaContent && !sceneNavigation) { const totalMs = performance.now() - tRecallStart; logger?.info( `${TAG} ⏱ Recall timing: total=${totalMs.toFixed(0)}ms, ` + @@ -202,10 +205,13 @@ async function performAutoRecallInner(params: { } // Dynamic part: L1 relevant memories (changes every turn) → prependContext (user prompt) + // Keep the wrapper even for zero-hit searches so prompt shape stays stable. let prependContext: string | undefined; if (memoryLines.length > 0) { prependContext = `\n以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:\n\n${memoryLines.join(RECALL_LINE_SEPARATOR)}\n`; + } else if (memorySearchAttempted) { + prependContext = `\n${NO_RELEVANT_MEMORIES_PLACEHOLDER}\n`; } // Append memory tools usage guide to the stable part so the agent knows