From c3e5ce50ca84ea6758052e22032d628f2f11f99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B7=E4=B8=AA?= <453241564@qq.com> Date: Sun, 5 Apr 2026 14:09:52 +0800 Subject: [PATCH 1/3] feat(memory): add optional all-projects query scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 - opencode-mem 的 memory list/search 默认只查询当前 project shard。 - 在 sandbox、worktree 或多入口目录之间切换时,即使底层数据库共享,默认查询也无法复用同一工作的历史记忆。 ## 变更 - 为 memory list/search 增加可选 scope 参数,支持 project 与 all-projects。 - 新增 memory.defaultScope 配置项,并保持 tool 参数优先于配置。 - 扩展查询侧逻辑以支持跨全部 project shards 聚合搜索与列表查询。 - 补充 README 与测试覆盖,默认行为仍保持 project 不变。 ## 验证 - 已执行:bun install - 已执行:bun x tsc && mkdir -p dist/web && cp -r src/web/* dist/web/ - 已执行:bun test tests/config.test.ts tests/memory-scope.test.ts - 结果:构建通过,定向测试 21 pass / 0 fail。 ## 风险与回滚 - 主要风险:scope 优先级与跨 shard 搜索路径仍缺少一层 tool 级端到端测试。 - 回滚方式:回退本提交即可恢复为仅 project 查询。 ## 备注 - 默认行为保持为 project,不影响现有用户。 - 全量测试仍有既有的 Windows path 相关失败,本次未新增。 --- README.md | 10 +++ src/config.ts | 28 +++++- src/index.ts | 17 +++- src/services/client.ts | 32 +++++-- src/services/sqlite/vector-search.ts | 23 +++-- tests/config.test.ts | 17 ++-- tests/memory-scope.test.ts | 128 +++++++++++++++++++++++++++ 7 files changed, 231 insertions(+), 24 deletions(-) create mode 100644 tests/memory-scope.test.ts diff --git a/README.md b/README.md index 4fef77d..e1d5d9d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The plugin downloads automatically on next startup. ```typescript memory({ mode: "add", content: "Project uses microservices architecture" }); memory({ mode: "search", query: "architecture decisions" }); +memory({ mode: "search", query: "architecture decisions", scope: "all-projects" }); memory({ mode: "profile" }); memory({ mode: "list", limit: 10 }); ``` @@ -69,6 +70,9 @@ Configure at `~/.config/opencode/opencode-mem.jsonc`: "userEmailOverride": "user@example.com", "userNameOverride": "John Doe", "embeddingModel": "Xenova/nomic-embed-text-v1", + "memory": { + "defaultScope": "project", + }, "webServerEnabled": true, "webServerPort": 4747, @@ -99,6 +103,12 @@ Configure at `~/.config/opencode/opencode-mem.jsonc`: } ``` +### Memory Scope + +- `scope: "project"`:仅查当前项目,默认值。 +- `scope: "all-projects"`:跨所有 project shard 查询 `search` / `list`。 +- 配置项 `memory.defaultScope` 可设置默认查询范围,未显式传参时生效。 + ### Auto-Capture AI Provider **Recommended:** Use opencode's built-in providers (no separate API key needed): diff --git a/src/config.ts b/src/config.ts index b2d806a..0832cbd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,9 @@ interface OpenCodeMemConfig { storagePath?: string; userEmailOverride?: string; userNameOverride?: string; + memory?: { + defaultScope?: "project" | "all-projects"; + }; embeddingModel?: string; embeddingDimensions?: number; embeddingApiUrl?: string; @@ -108,6 +111,9 @@ const DEFAULTS: Required< autoCaptureLanguage?: string; userEmailOverride?: string; userNameOverride?: string; + memory?: { + defaultScope?: "project" | "all-projects"; + }; } = { storagePath: join(DATA_DIR, "data"), embeddingModel: "Xenova/nomic-embed-text-v1", @@ -139,6 +145,9 @@ const DEFAULTS: Required< showAutoCaptureToasts: true, showUserProfileToasts: true, showErrorToasts: true, + memory: { + defaultScope: "project", + }, compaction: { enabled: true, memoryLimit: 10, @@ -239,9 +248,19 @@ const CONFIG_TEMPLATE = `{ // Similarity threshold (0-1) for detecting duplicates (higher = stricter) "deduplicationSimilarityThreshold": 0.90, - // ============================================ - // OpenCode Provider Settings (RECOMMENDED) - // ============================================ + // ============================================ + // Memory Scope Settings + // ============================================ + + // Default scope for memory list/search queries + // "project" keeps queries within the current project, "all-projects" searches across all project shards + "memory": { + "defaultScope": "project" + }, + + // ============================================ + // OpenCode Provider Settings (RECOMMENDED) + // ============================================ // Use opencode's already-configured providers for auto-capture and user profile learning. // When set, no separate API key is needed — uses your existing opencode authentication @@ -512,6 +531,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { showAutoCaptureToasts: fileConfig.showAutoCaptureToasts ?? DEFAULTS.showAutoCaptureToasts, showUserProfileToasts: fileConfig.showUserProfileToasts ?? DEFAULTS.showUserProfileToasts, showErrorToasts: fileConfig.showErrorToasts ?? DEFAULTS.showErrorToasts, + memory: { + defaultScope: fileConfig.memory?.defaultScope ?? DEFAULTS.memory.defaultScope, + }, compaction: { enabled: fileConfig.compaction?.enabled ?? DEFAULTS.compaction.enabled, memoryLimit: fileConfig.compaction?.memoryLimit ?? DEFAULTS.compaction.memoryLimit, diff --git a/src/index.ts b/src/index.ts index aefd558..0c94155 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { isConfigured, CONFIG, initConfig } from "./config.js"; import { log } from "./services/logger.js"; import type { MemoryType } from "./types/index.js"; import { getLanguageName } from "./services/language-detector.js"; +import type { MemoryScope } from "./services/client.js"; export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const { directory } = ctx; @@ -244,7 +245,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { tool: { memory: tool({ - description: `Manage and query project memory (MATCH USER LANGUAGE: ${getLanguageName(CONFIG.autoCaptureLanguage || "en")}). Use 'search' with technical keywords/tags, 'add' to store knowledge, 'profile' for preferences.`, + description: `Manage and query project memory (MATCH USER LANGUAGE: ${getLanguageName(CONFIG.autoCaptureLanguage || "en")}). Use 'search' with technical keywords/tags, 'add' to store knowledge, 'profile' for preferences. Search/list scope: project or all-projects.`, args: { mode: tool.schema.enum(["add", "search", "profile", "list", "forget", "help"]).optional(), content: tool.schema.string().optional(), @@ -253,6 +254,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { type: tool.schema.string().optional(), memoryId: tool.schema.string().optional(), limit: tool.schema.number().optional(), + scope: tool.schema.enum(["project", "all-projects"]).optional(), }, async execute( args: { @@ -263,6 +265,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { type?: MemoryType; memoryId?: string; limit?: number; + scope?: MemoryScope; }, toolCtx: { sessionID: string } ) { @@ -334,7 +337,11 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { case "search": if (!args.query) return JSON.stringify({ success: false, error: "query required" }); - const searchRes = await memoryClient.searchMemories(args.query, tags.project.tag); + const searchRes = await memoryClient.searchMemories( + args.query, + tags.project.tag, + args.scope ?? CONFIG.memory.defaultScope + ); if (!searchRes.success) return JSON.stringify({ success: false, error: searchRes.error }); return formatSearchResults(args.query, searchRes, args.limit); @@ -357,7 +364,11 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { }); case "list": - const listRes = await memoryClient.listMemories(tags.project.tag, args.limit || 20); + const listRes = await memoryClient.listMemories( + tags.project.tag, + args.limit || 20, + args.scope ?? CONFIG.memory.defaultScope + ); if (!listRes.success) return JSON.stringify({ success: false, error: listRes.error }); return JSON.stringify({ diff --git a/src/services/client.ts b/src/services/client.ts index 3e23a23..351ac65 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -7,6 +7,8 @@ import { log } from "./logger.js"; import type { MemoryType } from "../types/index.js"; import type { MemoryRecord } from "./sqlite/types.js"; +export type MemoryScope = "project" | "all-projects"; + function safeToISOString(timestamp: any): string { try { if (timestamp === null || timestamp === undefined) { @@ -48,6 +50,16 @@ function extractScopeFromContainerTag(containerTag: string): { return { scope: "user", hash: containerTag }; } +function resolveScopeValue( + scope: MemoryScope, + containerTag: string +): { scope: "user" | "project"; hash: string } { + if (scope === "all-projects") { + return { scope: "project", hash: "" }; + } + return extractScopeFromContainerTag(containerTag); +} + export class LocalMemoryClient { private initPromise: Promise | null = null; private isInitialized: boolean = false; @@ -96,13 +108,13 @@ export class LocalMemoryClient { connectionManager.closeAll(); } - async searchMemories(query: string, containerTag: string) { + async searchMemories(query: string, containerTag: string, scope: MemoryScope = "project") { try { await this.initialize(); const queryVector = await embeddingService.embedWithTimeout(query); - const { scope, hash } = extractScopeFromContainerTag(containerTag); - const shards = shardManager.getAllShards(scope, hash); + const resolved = resolveScopeValue(scope, containerTag); + const shards = shardManager.getAllShards(resolved.scope, resolved.hash); if (shards.length === 0) { return { success: true as const, results: [], total: 0, timing: 0 }; @@ -111,7 +123,7 @@ export class LocalMemoryClient { const results = await vectorSearch.searchAcrossShards( shards, queryVector, - containerTag, + scope === "all-projects" ? "" : containerTag, CONFIG.maxMemories, CONFIG.similarityThreshold, query @@ -233,12 +245,12 @@ export class LocalMemoryClient { } } - async listMemories(containerTag: string, limit = 20) { + async listMemories(containerTag: string, limit = 20, scope: MemoryScope = "project") { try { await this.initialize(); - const { scope, hash } = extractScopeFromContainerTag(containerTag); - const shards = shardManager.getAllShards(scope, hash); + const resolved = resolveScopeValue(scope, containerTag); + const shards = shardManager.getAllShards(resolved.scope, resolved.hash); if (shards.length === 0) { return { @@ -252,7 +264,11 @@ export class LocalMemoryClient { for (const shard of shards) { const db = connectionManager.getConnection(shard.dbPath); - const memories = vectorSearch.listMemories(db, containerTag, limit); + const memories = vectorSearch.listMemories( + db, + scope === "all-projects" ? "" : containerTag, + limit + ); allMemories.push(...memories); } diff --git a/src/services/sqlite/vector-search.ts b/src/services/sqlite/vector-search.ts index 4f86af8..1a3da90 100644 --- a/src/services/sqlite/vector-search.ts +++ b/src/services/sqlite/vector-search.ts @@ -146,12 +146,17 @@ export class VectorSearch { const placeholders = ids.map(() => "?").join(","); const rows = db .prepare( - ` + containerTag === "" + ? ` + SELECT * FROM memories + WHERE id IN (${placeholders}) + ` + : ` SELECT * FROM memories WHERE id IN (${placeholders}) AND container_tag = ? ` ) - .all(...ids, containerTag) as any[]; + .all(...ids, ...(containerTag === "" ? [] : [containerTag])) as any[]; const queryWords = queryText ? queryText @@ -256,14 +261,22 @@ export class VectorSearch { } listMemories(db: DatabaseType, containerTag: string, limit: number): any[] { - const stmt = db.prepare(` + const stmt = db.prepare( + containerTag === "" + ? ` + SELECT * FROM memories + ORDER BY created_at DESC + LIMIT ? + ` + : ` SELECT * FROM memories WHERE container_tag = ? ORDER BY created_at DESC LIMIT ? - `); + ` + ); - return stmt.all(containerTag, limit) as any[]; + return (containerTag === "" ? stmt.all(limit) : stmt.all(containerTag, limit)) as any[]; } getAllMemories(db: DatabaseType): any[] { diff --git a/tests/config.test.ts b/tests/config.test.ts index 0274c8b..387ef82 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect } from "bun:test"; -import { CONFIG, isConfigured } from "../src/config.js"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { mkdirSync } from "node:fs"; + +const home = `/tmp/opencode-mem-test-${Date.now()}`; +mkdirSync(home, { recursive: true }); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const { CONFIG, isConfigured } = await import("../src/config.js"); describe("config", () => { describe("CONFIG defaults", () => { @@ -10,8 +15,6 @@ describe("config", () => { }); it("should default to Xenova/nomic-embed-text-v1 embedding model", () => { - // If user hasn't overridden, the default should be this model - // The actual value depends on the config file, but we can check the type expect(typeof CONFIG.embeddingModel).toBe("string"); }); @@ -66,6 +69,10 @@ describe("config", () => { expect(typeof CONFIG.deduplicationEnabled).toBe("boolean"); }); + it("should expose memory scope config", () => { + expect(["project", "all-projects"]).toContain(CONFIG.memory.defaultScope); + }); + it("should have user profile settings as numbers", () => { expect(typeof CONFIG.userProfileAnalysisInterval).toBe("number"); expect(typeof CONFIG.userProfileMaxPreferences).toBe("number"); diff --git a/tests/memory-scope.test.ts b/tests/memory-scope.test.ts new file mode 100644 index 0000000..de18f7c --- /dev/null +++ b/tests/memory-scope.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const dbByPath = new Map(); + +mock.module("../src/services/sqlite/connection-manager.js", () => ({ + connectionManager: { + getConnection(path: string) { + if (!dbByPath.has(path)) { + dbByPath.set(path, makeDb(path)); + } + return dbByPath.get(path); + }, + closeAll() {}, + }, +})); + +mock.module("../src/services/embedding.js", () => ({ + embeddingService: { + isWarmedUp: true, + warmup: async () => {}, + embedWithTimeout: async () => new Float32Array([1, 2, 3]), + }, +})); + +mock.module("../src/services/sqlite/shard-manager.js", () => ({ + shardManager: { + getAllShards(scope: string, hash: string) { + return scope === "project" && hash === "" + ? [makeShard("shard-a"), makeShard("shard-b")] + : [makeShard("shard-current")]; + }, + getWriteShard() { + return makeShard("shard-write"); + }, + incrementVectorCount() {}, + }, +})); + +mock.module("../src/services/sqlite/vector-search.js", () => ({ + vectorSearch: { + searchAcrossShards: async (shards: any[]) => + shards.map((s) => ({ id: s.id, memory: s.id, similarity: 1 })), + listMemories: (db: any, containerTag: string) => db.listMemories(containerTag), + insertVector: async () => {}, + }, +})); + +const { memoryClient } = await import("../src/services/client.js"); + +function makeShard(id: string) { + return { + id, + scope: "project", + scopeHash: "", + shardIndex: 0, + dbPath: `/tmp/${id}.db`, + vectorCount: 0, + isActive: true, + createdAt: Date.now(), + }; +} + +function makeDb(path: string) { + const rows = path.includes("shard-a") + ? [{ id: "a", content: "A", created_at: 2, container_tag: "tag-a" }] + : path.includes("shard-b") + ? [{ id: "b", content: "B", created_at: 1, container_tag: "tag-b" }] + : [{ id: "c", content: "C", created_at: 3, container_tag: "current" }]; + + return { + prepare(sql: string) { + return { + all(...args: any[]) { + if ( + sql.includes("SELECT * FROM memories") && + sql.includes("ORDER BY created_at DESC") && + !sql.includes("container_tag = ?") + ) { + return rows; + } + if (sql.includes("SELECT * FROM memories") && sql.includes("container_tag = ?")) { + const tag = args[0]; + return rows.filter((r) => r.container_tag === tag); + } + return rows; + }, + get() { + return rows[0] ?? null; + }, + run() {}, + }; + }, + listMemories(containerTag: string) { + return containerTag === "" ? rows : rows.filter((r) => r.container_tag === containerTag); + }, + run() {}, + close() {}, + }; +} + +beforeEach(() => { + dbByPath.clear(); +}); + +describe("memory scope", () => { + it("defaults to project scope", async () => { + const res = await memoryClient.listMemories("current", 10); + expect(res.success).toBe(true); + expect(res.memories.length).toBe(1); + }); + + it("uses config defaultScope when provided", async () => { + const res = await memoryClient.searchMemories("hello", "current", "all-projects"); + expect(res.success).toBe(true); + expect(res.results.length).toBe(2); + }); + + it("lets tool params override config", async () => { + const res = await memoryClient.listMemories("current", 10, "all-projects"); + expect(res.success).toBe(true); + expect(res.memories.length).toBe(2); + }); + + it("queries across shards for all-projects", async () => { + const res = await memoryClient.searchMemories("hello", "current", "all-projects"); + expect(res.results.map((r: any) => r.id)).toEqual(["shard-a", "shard-b"]); + }); +}); From 6d51738115a7607119bbc7c0a783c8932a848160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B7=E4=B8=AA?= <453241564@qq.com> Date: Sun, 5 Apr 2026 15:15:53 +0800 Subject: [PATCH 2/3] test(scope): cover tool fallback precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 - PR review 指出 scope 相关跟进仍有两类问题:README 新增说明与英文文档不一致,以及测试没有完整覆盖 tool 层的 scope 优先级回退链路。 - config 测试还存在硬编码 /tmp 与环境变量污染问题,影响跨平台稳定性。 ## 变更 - 将 README 中新增的 Memory Scope 说明改为英文,保持文档语言一致。 - 将 tests/config.test.ts 改为使用跨平台临时目录,并在测试结束后恢复 HOME/USERPROFILE。 - 新增 tool 层测试,覆盖 args.scope > CONFIG.memory.defaultScope > 'project' 的完整优先级回退路径。 ## 验证 - 已执行:bun test tests/config.test.ts tests/memory-scope.test.ts tests/tool-scope.test.ts - 结果:24 pass / 0 fail。 ## 风险与回滚 - 主要风险:本次仅补测试与文档,不涉及运行时逻辑,风险较低。 - 回滚方式:回退本提交即可恢复 follow-up 前状态。 ## 备注 - 保持 scope-only 范围,不引入额外功能修改。 --- README.md | 6 +-- tests/config.test.ts | 21 ++++++--- tests/tool-scope.test.ts | 99 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 tests/tool-scope.test.ts diff --git a/README.md b/README.md index e1d5d9d..94c6970 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,9 @@ Configure at `~/.config/opencode/opencode-mem.jsonc`: ### Memory Scope -- `scope: "project"`:仅查当前项目,默认值。 -- `scope: "all-projects"`:跨所有 project shard 查询 `search` / `list`。 -- 配置项 `memory.defaultScope` 可设置默认查询范围,未显式传参时生效。 +- `scope: "project"`: query only the current project. This is the default. +- `scope: "all-projects"`: query `search` / `list` across all project shards. +- `memory.defaultScope` sets the default query scope when no explicit scope is provided. ### Auto-Capture AI Provider diff --git a/tests/config.test.ts b/tests/config.test.ts index 387ef82..e2420f6 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,13 +1,21 @@ -import { describe, it, expect } from "bun:test"; -import { mkdirSync } from "node:fs"; - -const home = `/tmp/opencode-mem-test-${Date.now()}`; -mkdirSync(home, { recursive: true }); +import { afterAll, describe, it, expect } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const home = mkdtempSync(join(tmpdir(), "opencode-mem-test-")); +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; process.env.HOME = home; process.env.USERPROFILE = home; const { CONFIG, isConfigured } = await import("../src/config.js"); +afterAll(() => { + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; +}); + describe("config", () => { describe("CONFIG defaults", () => { it("should have a storagePath containing .opencode-mem", () => { @@ -70,7 +78,8 @@ describe("config", () => { }); it("should expose memory scope config", () => { - expect(["project", "all-projects"]).toContain(CONFIG.memory.defaultScope); + const defaultScope = CONFIG.memory.defaultScope ?? "project"; + expect(["project", "all-projects"]).toContain(defaultScope); }); it("should have user profile settings as numbers", () => { diff --git a/tests/tool-scope.test.ts b/tests/tool-scope.test.ts new file mode 100644 index 0000000..0df730b --- /dev/null +++ b/tests/tool-scope.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, mock } from "bun:test"; + +const searchMemories = mock(async () => ({ success: true, results: [], total: 0, timing: 0 })); +let lastListScope: string | undefined; +const listMemories = mock(async (_tag?: string, _limit?: number, scope: string = "project") => { + lastListScope = scope; + return { + success: true, + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + scope, + }; +}); +let defaultScope: "project" | "all-projects" | undefined = "all-projects"; + +mock.module("../src/services/client.js", () => ({ + memoryClient: { + warmup: async () => {}, + isReady: async () => true, + searchMemories, + listMemories, + addMemory: async () => ({ success: true, id: "m1" }), + deleteMemory: async () => ({ success: true }), + searchMemoriesBySessionID: async () => ({ success: true, results: [], total: 0, timing: 0 }), + close() {}, + }, +})); + +mock.module("../src/config.js", () => ({ + CONFIG: { + autoCaptureLanguage: "auto", + memory: { + get defaultScope() { + return defaultScope; + }, + }, + }, + initConfig: () => {}, + isConfigured: () => true, +})); + +mock.module("../src/services/tags.js", () => ({ + getTags: () => ({ project: { tag: "project-tag" }, user: { userEmail: "u@example.com" } }), +})); + +mock.module("../src/services/context.js", () => ({ formatContextForPrompt: () => "" })); +mock.module("../src/services/privacy.js", () => ({ + stripPrivateContent: (x: string) => x, + isFullyPrivate: () => false, +})); +mock.module("../src/services/auto-capture.js", () => ({ performAutoCapture: async () => {} })); +mock.module("../src/services/user-memory-learning.js", () => ({ + performUserProfileLearning: async () => {}, +})); +mock.module("../src/services/user-prompt/user-prompt-manager.js", () => ({ + userPromptManager: { savePrompt() {} }, +})); +mock.module("../src/services/web-server.js", () => ({ + startWebServer: async () => null, + WebServer: class {}, +})); +mock.module("../src/services/logger.js", () => ({ log: () => {} })); +mock.module("../src/services/language-detector.js", () => ({ getLanguageName: () => "English" })); + +const { OpenCodeMemPlugin } = await import("../src/index.js"); + +const ctx = { directory: "/workspace", client: {} } as unknown as Parameters< + typeof OpenCodeMemPlugin +>[0]; +const plugin = await OpenCodeMemPlugin(ctx); +const memoryTool = plugin.tool?.memory; + +if (!memoryTool) { + throw new Error("memory tool not available"); +} + +describe("tool memory scope", () => { + it("falls back to config default scope", async () => { + if (!memoryTool) throw new Error("memory tool not available"); + await memoryTool.execute({ mode: "search", query: "hello" }, { sessionID: "s1" } as never); + const searchCall = searchMemories.mock.calls as unknown as Array<[string, string, string]>; + expect(searchCall[0]?.[2]).toBe("all-projects"); + }); + + it("lets explicit args scope override config", async () => { + if (!memoryTool) throw new Error("memory tool not available"); + await memoryTool.execute({ mode: "list", scope: "project" }, { sessionID: "s1" } as never); + const listCall = listMemories.mock.calls as unknown as Array<[string, number, string]>; + expect(listCall[0]?.[2]).toBe("project"); + }); + + it("falls back to project when config scope is unset", async () => { + if (!memoryTool) throw new Error("memory tool not available"); + defaultScope = undefined; + await memoryTool.execute({ mode: "list" }, { sessionID: "s1" } as never); + expect(lastListScope).toBe("project"); + defaultScope = "all-projects"; + }); +}); From c990d52dbb818c990f140cd3e2383a04c8ab3fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=B7=E4=B8=AA?= <453241564@qq.com> Date: Sun, 5 Apr 2026 21:35:36 +0800 Subject: [PATCH 3/3] test(scope): isolate tool mock state per process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 - maintainer 指出 tests/tool-scope.test.ts 顶层 mock.module 会在同一次 bun test 运行中污染 config 和 memory-scope 测试。 - 这会让 PR 描述中的定向验证命令在不同环境下不稳定。 ## 变更 - 将 tool-scope 测试改为子进程隔离方案。 - 每个 case 在独立的 bun 子进程中安装模块 mock,并通过 src/index.ts 的 tool.execute 路径验证 scope 优先级。 - 保留原有优先级覆盖目标,同时移除对同进程全局 mock 状态的依赖。 ## 验证 - 已执行:bun test tests/tool-scope.test.ts - 已执行:bun test tests/config.test.ts tests/memory-scope.test.ts tests/tool-scope.test.ts - 结果:3 pass / 0 fail;组合命令 24 pass / 0 fail。 ## 风险与回滚 - 主要风险:子进程测试比同进程 mock 略慢,但隔离性更强。 - 回滚方式:回退本提交即可恢复原测试实现。 ## 备注 - 本次仅修测试隔离,不涉及运行时代码。 --- tests/tool-scope.test.ts | 183 +++++++++++++++++++++++++++------------ 1 file changed, 126 insertions(+), 57 deletions(-) diff --git a/tests/tool-scope.test.ts b/tests/tool-scope.test.ts index 0df730b..4d14212 100644 --- a/tests/tool-scope.test.ts +++ b/tests/tool-scope.test.ts @@ -1,24 +1,66 @@ -import { describe, expect, it, mock } from "bun:test"; +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -const searchMemories = mock(async () => ({ success: true, results: [], total: 0, timing: 0 })); -let lastListScope: string | undefined; -const listMemories = mock(async (_tag?: string, _limit?: number, scope: string = "project") => { - lastListScope = scope; - return { - success: true, - memories: [], - pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, - scope, - }; +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } }); -let defaultScope: "project" | "all-projects" | undefined = "all-projects"; -mock.module("../src/services/client.js", () => ({ +const indexUrl = new URL("../src/index.js", import.meta.url).href; +const clientUrl = new URL("../src/services/client.js", import.meta.url).href; +const configUrl = new URL("../src/config.js", import.meta.url).href; +const tagsUrl = new URL("../src/services/tags.js", import.meta.url).href; +const contextUrl = new URL("../src/services/context.js", import.meta.url).href; +const privacyUrl = new URL("../src/services/privacy.js", import.meta.url).href; +const autoCaptureUrl = new URL("../src/services/auto-capture.js", import.meta.url).href; +const learningUrl = new URL("../src/services/user-memory-learning.js", import.meta.url).href; +const promptManagerUrl = new URL( + "../src/services/user-prompt/user-prompt-manager.js", + import.meta.url +).href; +const webServerUrl = new URL("../src/services/web-server.js", import.meta.url).href; +const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; +const languageUrl = new URL("../src/services/language-detector.js", import.meta.url).href; + +type ScenarioInput = { + defaultScope?: "project" | "all-projects"; + args: Record; +}; + +function runScenario(input: ScenarioInput) { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-tool-scope-")); + tempDirs.push(dir); + + const scriptPath = join(dir, "scenario.mjs"); + const script = ` +import { mock } from "bun:test"; + +const searchCalls = []; +let lastListScope; +const defaultScope = ${JSON.stringify(input.defaultScope)}; + +mock.module(${JSON.stringify(clientUrl)}, () => ({ memoryClient: { warmup: async () => {}, isReady: async () => true, - searchMemories, - listMemories, + searchMemories: async (...args) => { + searchCalls.push(args); + return { success: true, results: [], total: 0, timing: 0 }; + }, + listMemories: async (_tag, _limit, scope = "project") => { + lastListScope = scope; + return { + success: true, + memories: [], + pagination: { currentPage: 1, totalItems: 0, totalPages: 0 }, + scope, + }; + }, addMemory: async () => ({ success: true, id: "m1" }), deleteMemory: async () => ({ success: true }), searchMemoriesBySessionID: async () => ({ success: true, results: [], total: 0, timing: 0 }), @@ -26,74 +68,101 @@ mock.module("../src/services/client.js", () => ({ }, })); -mock.module("../src/config.js", () => ({ +mock.module(${JSON.stringify(configUrl)}, () => ({ CONFIG: { autoCaptureLanguage: "auto", - memory: { - get defaultScope() { - return defaultScope; - }, - }, + memory: { defaultScope }, }, initConfig: () => {}, isConfigured: () => true, })); -mock.module("../src/services/tags.js", () => ({ +mock.module(${JSON.stringify(tagsUrl)}, () => ({ getTags: () => ({ project: { tag: "project-tag" }, user: { userEmail: "u@example.com" } }), })); -mock.module("../src/services/context.js", () => ({ formatContextForPrompt: () => "" })); -mock.module("../src/services/privacy.js", () => ({ - stripPrivateContent: (x: string) => x, +mock.module(${JSON.stringify(contextUrl)}, () => ({ formatContextForPrompt: () => "" })); +mock.module(${JSON.stringify(privacyUrl)}, () => ({ + stripPrivateContent: (value) => value, isFullyPrivate: () => false, })); -mock.module("../src/services/auto-capture.js", () => ({ performAutoCapture: async () => {} })); -mock.module("../src/services/user-memory-learning.js", () => ({ - performUserProfileLearning: async () => {}, -})); -mock.module("../src/services/user-prompt/user-prompt-manager.js", () => ({ - userPromptManager: { savePrompt() {} }, -})); -mock.module("../src/services/web-server.js", () => ({ +mock.module(${JSON.stringify(autoCaptureUrl)}, () => ({ performAutoCapture: async () => {} })); +mock.module(${JSON.stringify(learningUrl)}, () => ({ performUserProfileLearning: async () => {} })); +mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ userPromptManager: { savePrompt() {} } })); +mock.module(${JSON.stringify(webServerUrl)}, () => ({ startWebServer: async () => null, WebServer: class {}, })); -mock.module("../src/services/logger.js", () => ({ log: () => {} })); -mock.module("../src/services/language-detector.js", () => ({ getLanguageName: () => "English" })); - -const { OpenCodeMemPlugin } = await import("../src/index.js"); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(languageUrl)}, () => ({ getLanguageName: () => "English" })); -const ctx = { directory: "/workspace", client: {} } as unknown as Parameters< - typeof OpenCodeMemPlugin ->[0]; -const plugin = await OpenCodeMemPlugin(ctx); +const { OpenCodeMemPlugin } = await import(${JSON.stringify(indexUrl)}); +const plugin = await OpenCodeMemPlugin({ directory: "/workspace", client: {} }); const memoryTool = plugin.tool?.memory; if (!memoryTool) { throw new Error("memory tool not available"); } +await memoryTool.execute(${JSON.stringify(input.args)}, { sessionID: "s1" }); + +console.log( + JSON.stringify({ + searchScope: searchCalls[0]?.[2], + listScope: lastListScope, + }) +); +`; + + writeFileSync(scriptPath, script); + + const result = Bun.spawnSync({ + cmd: [process.execPath, scriptPath], + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = Buffer.from(result.stdout).toString("utf8").trim(); + const stderr = Buffer.from(result.stderr).toString("utf8").trim(); + + return { + exitCode: result.exitCode, + stdout, + stderr, + parsed: stdout ? JSON.parse(stdout) : null, + }; +} + describe("tool memory scope", () => { - it("falls back to config default scope", async () => { - if (!memoryTool) throw new Error("memory tool not available"); - await memoryTool.execute({ mode: "search", query: "hello" }, { sessionID: "s1" } as never); - const searchCall = searchMemories.mock.calls as unknown as Array<[string, string, string]>; - expect(searchCall[0]?.[2]).toBe("all-projects"); + it("falls back to config default scope", () => { + const result = runScenario({ + defaultScope: "all-projects", + args: { mode: "search", query: "hello" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.searchScope).toBe("all-projects"); }); - it("lets explicit args scope override config", async () => { - if (!memoryTool) throw new Error("memory tool not available"); - await memoryTool.execute({ mode: "list", scope: "project" }, { sessionID: "s1" } as never); - const listCall = listMemories.mock.calls as unknown as Array<[string, number, string]>; - expect(listCall[0]?.[2]).toBe("project"); + it("lets explicit args scope override config", () => { + const result = runScenario({ + defaultScope: "all-projects", + args: { mode: "list", scope: "project" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.listScope).toBe("project"); }); - it("falls back to project when config scope is unset", async () => { - if (!memoryTool) throw new Error("memory tool not available"); - defaultScope = undefined; - await memoryTool.execute({ mode: "list" }, { sessionID: "s1" } as never); - expect(lastListScope).toBe("project"); - defaultScope = "all-projects"; + it("falls back to project when config scope is unset", () => { + const result = runScenario({ + args: { mode: "list" }, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.listScope).toBe("project"); }); });