diff --git a/src/v2/runtime.test.ts b/src/v2/runtime.test.ts index bb05a5a..58993f9 100644 --- a/src/v2/runtime.test.ts +++ b/src/v2/runtime.test.ts @@ -9,6 +9,7 @@ import { buildTranscriptTurns, buildV2RecallDirective, mergeTurns, + setupV2, SUPERMEMORY_RECALL_TOOL_NAME, SUPERMEMORY_TOOL_NAME, V2Runtime, @@ -124,6 +125,11 @@ interface FakeTool { execute: (input: unknown, context: unknown) => Promise<{ content?: unknown }>; } +interface FakeRegistration { + disposeCalls: number; + dispose: () => Promise; +} + interface Harness { ctx: V2Context; runtime: V2Runtime; @@ -136,9 +142,23 @@ interface Harness { transcript: TranscriptMessage[]; } -function harness( +interface HarnessEnvironment { + ctx: V2Context; + deps: Partial; + tools: Map; + hooks: Harness["hooks"]; + registrations: FakeRegistration[]; + queries: string[]; + writes: Harness["writes"]; + adds: Harness["adds"]; + emitted: Harness["emitted"]; + state: { transcript: TranscriptMessage[] }; +} + +function environment( config: Partial = {}, -): Harness { + directory: string = "/repo", +): HarnessEnvironment { const tools = new Map(); const hooks: Harness["hooks"] = {}; const queries: string[] = []; @@ -146,14 +166,38 @@ function harness( const adds: Harness["adds"] = []; const emitted: Harness["emitted"] = []; const state: { transcript: TranscriptMessage[] } = { transcript: [] }; - const registration = { dispose: async () => undefined }; + const registrations: FakeRegistration[] = []; + + const registration = (remove: () => void = () => undefined) => { + let disposed = false; + const handle: FakeRegistration = { + disposeCalls: 0, + dispose: async () => { + handle.disposeCalls++; + if (disposed) return; + disposed = true; + remove(); + }, + }; + registrations.push(handle); + return handle; + }; + + const hook = (name: string, callback: Harness["hooks"][string]) => { + hooks[name] = callback; + return registration(() => { + if (hooks[name] === callback) delete hooks[name]; + }); + }; const ctx = { - location: { directory: "/repo" }, + location: { directory }, tool: { transform: async (callback: (editor: unknown) => void) => { + const added: FakeTool[] = []; callback({ add: (tool: FakeTool) => { + added.push(tool); tools.set(tool.name, tool); }, list: () => [], @@ -162,35 +206,32 @@ function harness( update: () => undefined, remove: () => undefined, }); - return registration; - }, - hook: async (name: string, callback: Harness["hooks"][string]) => { - hooks[`tool.${name}`] = callback; - return registration; + return registration(() => { + for (const tool of added) { + if (tools.get(tool.name) === tool) tools.delete(tool.name); + } + }); }, + hook: async (name: string, callback: Harness["hooks"][string]) => + hook(`tool.${name}`, callback), reload: async () => undefined, list: async () => [], }, session: { - hook: async (name: string, callback: Harness["hooks"][string]) => { - hooks[`session.${name}`] = callback; - return registration; - }, + hook: async (name: string, callback: Harness["hooks"][string]) => + hook(`session.${name}`, callback), get: async ({ sessionID }: { sessionID: string }) => ({ id: sessionID, - location: { directory: "/repo" }, + location: { directory }, }), context: async () => state.transcript, }, permission: { - hook: async (name: string, callback: Harness["hooks"][string]) => { - hooks[`permission.${name}`] = callback; - return registration; - }, + hook: async (name: string, callback: Harness["hooks"][string]) => + hook(`permission.${name}`, callback), }, rpc: { - register: async () => ({ - ...registration, + register: async () => Object.assign(registration(), { events: { emit: async (_name: string, data: Record) => { emitted.push(data); @@ -250,7 +291,7 @@ function harness( deleteMemory: async () => ({ success: true }), }; - const runtime = new V2Runtime(ctx as unknown as V2Context, { + const deps: Partial = { configured: true, config: { recallMode: "direct", @@ -270,22 +311,41 @@ function harness( latestVersion: "9.9.9", updateCommand: "bunx opencode-supermemory@latest install", }), - }); + }; return { ctx: ctx as unknown as V2Context, - runtime, + deps, tools, hooks, + registrations, queries, writes, adds, emitted, + state, + }; +} + +function harness( + config: Partial = {}, +): Harness { + const env = environment(config); + const runtime = new V2Runtime(env.ctx, env.deps); + return { + ctx: env.ctx, + runtime, + tools: env.tools, + hooks: env.hooks, + queries: env.queries, + writes: env.writes, + adds: env.adds, + emitted: env.emitted, get transcript() { - return state.transcript; + return env.state.transcript; }, set transcript(value: TranscriptMessage[]) { - state.transcript = value; + env.state.transcript = value; }, }; } @@ -464,3 +524,211 @@ describe("OpenCode 2 runtime", () => { h.runtime.cleanup(); }); }); + +async function recallText(env: HarnessEnvironment): Promise { + const tool = env.tools.get(SUPERMEMORY_RECALL_TOOL_NAME)!; + const result = await tool.execute({ query: "bun" }, { sessionID: "s1" }); + return String(result.content); +} + +async function recallAllowed(env: HarnessEnvironment): Promise { + const evaluation = { action: SUPERMEMORY_RECALL_TOOL_NAME, effect: "ask" }; + await env.hooks["permission.evaluate"]!(evaluation); + return evaluation.effect === "allow"; +} + +function expectDisposalCalls(registrations: FakeRegistration[], count: number): void { + expect(registrations).toHaveLength(7); + expect(registrations.map((registration) => registration.disposeCalls)).toEqual( + Array(7).fill(count), + ); +} + +function expectRegistry(env: HarnessEnvironment, registered: boolean): void { + expect([...env.tools.keys()].sort()).toEqual(registered ? [ + SUPERMEMORY_TOOL_NAME, + SUPERMEMORY_RECALL_TOOL_NAME, + ].sort() : []); + expect(Object.keys(env.hooks).sort()).toEqual(registered ? [ + "permission.evaluate", + "session.compaction", + "session.context", + "tool.execute.after", + "tool.execute.before", + ] : []); + expectDisposalCalls(env.registrations, registered ? 0 : 1); +} + +describe("OpenCode 2 setup ownership", () => { + test("fixture disposal is identity-aware and idempotent", async () => { + const env = environment({}, "/repo/fixture"); + const original = new V2Runtime(env.ctx, env.deps); + const replacement = new V2Runtime(env.ctx, env.deps); + await original.register(); + const originalRegistrations = env.registrations.slice(); + await replacement.register(); + const replacementRegistrations = env.registrations.slice(originalRegistrations.length); + const replacementTools = new Map(env.tools); + const replacementHooks = { ...env.hooks }; + + try { + original.cleanup(); + expectDisposalCalls(originalRegistrations, 1); + expectDisposalCalls(replacementRegistrations, 0); + expect(env.tools).toEqual(replacementTools); + expect(env.hooks).toEqual(replacementHooks); + expect(await recallText(env)).toContain('"success":true'); + expect(await recallAllowed(env)).toBe(true); + + for (const registration of originalRegistrations) await registration.dispose(); + expectDisposalCalls(originalRegistrations, 2); + expectDisposalCalls(replacementRegistrations, 0); + expect(env.tools).toEqual(replacementTools); + expect(env.hooks).toEqual(replacementHooks); + + replacement.cleanup(); + expectDisposalCalls(replacementRegistrations, 1); + expect(env.tools.size).toBe(0); + expect(Object.keys(env.hooks)).toEqual([]); + for (const registration of replacementRegistrations) await registration.dispose(); + expectDisposalCalls(replacementRegistrations, 2); + expect(env.tools.size).toBe(0); + expect(Object.keys(env.hooks)).toEqual([]); + } finally { + original.cleanup(); + replacement.cleanup(); + } + }); + + test("keeps two different locations registered and usable", async () => { + const first = environment({}, "/repo/one"); + const second = environment({}, "/repo/two"); + + const cleanupFirst = await setupV2(first.ctx, first.deps); + const cleanupSecond = await setupV2(second.ctx, second.deps); + + const firstTools = new Map(first.tools); + const firstHooks = { ...first.hooks }; + try { + expectRegistry(first, true); + expectRegistry(second, true); + expect(await recallText(first)).toContain('"success":true'); + expect(await recallText(second)).toContain('"success":true'); + expect(await recallAllowed(first)).toBe(true); + expect(await recallAllowed(second)).toBe(true); + + cleanupSecond(); + cleanupSecond(); + expectRegistry(second, false); + expectRegistry(first, true); + expect(first.tools).toEqual(firstTools); + expect(first.hooks).toEqual(firstHooks); + expect(await recallText(first)).toContain('"success":true'); + expect(await recallAllowed(first)).toBe(true); + + cleanupFirst(); + cleanupFirst(); + expectRegistry(first, false); + expectRegistry(second, false); + } finally { + cleanupFirst(); + cleanupSecond(); + } + }); + + test("replaces only the same directory and ignores stale cleanup", async () => { + const directory = "/repo/same"; + const original = environment({}, directory); + const replacement = environment({}, directory); + const other = environment({}, "/repo/other"); + + const cleanupOriginal = await setupV2(original.ctx, original.deps); + const cleanupOther = await setupV2(other.ctx, other.deps); + let cleanupReplacement: () => void = () => undefined; + const otherTools = new Map(other.tools); + const otherHooks = { ...other.hooks }; + try { + expectRegistry(original, true); + expectRegistry(other, true); + const originalRecall = original.tools.get(SUPERMEMORY_RECALL_TOOL_NAME)!; + const originalEvaluate = original.hooks["permission.evaluate"]!; + + cleanupReplacement = await setupV2(replacement.ctx, replacement.deps); + const replacementTools = new Map(replacement.tools); + const replacementHooks = { ...replacement.hooks }; + expectRegistry(original, false); + expectRegistry(replacement, true); + expectRegistry(other, true); + expect(String((await originalRecall.execute({ query: "bun" }, { sessionID: "s1" })).content)) + .toContain('"success":false'); + const evaluation = { action: SUPERMEMORY_RECALL_TOOL_NAME, effect: "ask" }; + await originalEvaluate(evaluation); + expect(evaluation.effect).toBe("ask"); + expect(await recallText(replacement)).toContain('"success":true'); + expect(await recallText(other)).toContain('"success":true'); + expect(await recallAllowed(replacement)).toBe(true); + expect(await recallAllowed(other)).toBe(true); + + cleanupOriginal(); + cleanupOriginal(); + expectRegistry(original, false); + expectRegistry(replacement, true); + expectRegistry(other, true); + expect(replacement.tools).toEqual(replacementTools); + expect(replacement.hooks).toEqual(replacementHooks); + expect(other.tools).toEqual(otherTools); + expect(other.hooks).toEqual(otherHooks); + expect(await recallText(replacement)).toContain('"success":true'); + expect(await recallText(other)).toContain('"success":true'); + expect(await recallAllowed(replacement)).toBe(true); + expect(await recallAllowed(other)).toBe(true); + + cleanupReplacement(); + cleanupReplacement(); + expectRegistry(replacement, false); + expectRegistry(other, true); + expect(other.tools).toEqual(otherTools); + expect(other.hooks).toEqual(otherHooks); + expect(await recallText(other)).toContain('"success":true'); + expect(await recallAllowed(other)).toBe(true); + + cleanupOther(); + cleanupOther(); + expectRegistry(other, false); + } finally { + cleanupOriginal(); + cleanupReplacement(); + cleanupOther(); + } + }); + + test("tearing down one directory leaves the other registered", async () => { + const left = environment({}, "/repo/left"); + const right = environment({}, "/repo/right"); + + const cleanupLeft = await setupV2(left.ctx, left.deps); + const cleanupRight = await setupV2(right.ctx, right.deps); + + const rightTools = new Map(right.tools); + const rightHooks = { ...right.hooks }; + try { + expectRegistry(left, true); + expectRegistry(right, true); + cleanupLeft(); + cleanupLeft(); + expectRegistry(left, false); + expectRegistry(right, true); + expect(right.tools).toEqual(rightTools); + expect(right.hooks).toEqual(rightHooks); + expect(await recallText(right)).toContain('"success":true'); + expect(await recallAllowed(right)).toBe(true); + + cleanupRight(); + cleanupRight(); + expectRegistry(right, false); + } finally { + cleanupLeft(); + cleanupRight(); + } + }); +}); diff --git a/src/v2/runtime.ts b/src/v2/runtime.ts index 2fee56b..ae4aeb9 100644 --- a/src/v2/runtime.ts +++ b/src/v2/runtime.ts @@ -1255,45 +1255,56 @@ export class V2Runtime { } } -const OWNER_KEY = Symbol.for("opencode-supermemory.v2.owner"); +const OWNER_REGISTRY_KEY = Symbol.for("opencode-supermemory.v2.owners"); interface GlobalOwner { generation: number; cleanup: () => void; } -function ownerRegistry(): Record { - return globalThis as unknown as Record; +function ownerRegistry(): Map { + const global = globalThis as unknown as Record< + symbol, + Map | undefined + >; + let registry = global[OWNER_REGISTRY_KEY]; + if (!registry) { + registry = new Map(); + global[OWNER_REGISTRY_KEY] = registry; + } + return registry; } /** - * Starts the OpenCode 2 runtime. Only one instance is active per process, so a - * hot-reloaded plugin replaces (and cleans up) the previous generation. + * Starts the OpenCode 2 runtime. Each location directory owns one active + * generation, so a hot-reloaded plugin replaces (and cleans up) the previous + * generation in that directory without affecting other locations. */ export async function setupV2( ctx: V2Context, options?: Partial, ): Promise<() => void> { const registry = ownerRegistry(); - const previous = registry[OWNER_KEY]; + const directory = ctx.location.directory; + const previous = registry.get(directory); previous?.cleanup(); const owner: GlobalOwner = { generation: (previous?.generation ?? 0) + 1, cleanup: () => undefined, }; - registry[OWNER_KEY] = owner; + registry.set(directory, owner); - const runtime = new V2Runtime(ctx, options, () => registry[OWNER_KEY] === owner); + const runtime = new V2Runtime(ctx, options, () => registry.get(directory) === owner); const cleanup = () => { runtime.cleanup(); - if (registry[OWNER_KEY] === owner) delete registry[OWNER_KEY]; + if (registry.get(directory) === owner) registry.delete(directory); }; owner.cleanup = cleanup; log("v2 plugin init", { generation: owner.generation, - directory: ctx.location?.directory, + directory, configured: runtime["active"] && (options?.configured ?? isConfigured()), });