From 7db21778303b4024bcb4073ce99540dd26d7c4be Mon Sep 17 00:00:00 2001 From: kuitos Date: Wed, 13 May 2026 14:36:24 +0800 Subject: [PATCH] fix: support default opencode session client --- src/index.ts | 4 +- src/recallSelector.ts | 30 +++-- test/index.test.ts | 60 +++++---- test/memory-recall-prefetch-e2e.test.ts | 8 +- test/recallSelector.test.ts | 155 ++++++++++++++++++------ 5 files changed, 175 insertions(+), 82 deletions(-) diff --git a/src/index.ts b/src/index.ts index 84917ce..34fbcb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { tool } from "@opencode-ai/plugin" import { parse, resolve } from "path" import { buildMemorySystemPrompt } from "./prompt.js" import { formatRecalledMemories, recallSelectedMemories, type RecalledMemory } from "./recall.js" -import { assertSupportedRecallSelectorClient, selectRelevantMemoryFilenames, type SessionClient } from "./recallSelector.js" +import { isSupportedRecallSelectorClient, selectRelevantMemoryFilenames, type SessionClient } from "./recallSelector.js" import { scanMemoryFiles, type MemoryHeader } from "./memoryScan.js" import { saveMemory, @@ -197,7 +197,7 @@ function startRecallPrefetch(input: { }): RecallPrefetch | undefined { if (!input.client || !isUsefulRecallQuery(input.query)) return undefined - assertSupportedRecallSelectorClient(input.client) + if (!isSupportedRecallSelectorClient(input.client)) return undefined const memoryDir = getMemoryDir(input.worktree) const headers = scanMemoryFiles(memoryDir).filter((header) => !input.alreadySurfaced.has(alreadySurfacedKey(header))) diff --git a/src/recallSelector.ts b/src/recallSelector.ts index d9090c7..e17655a 100644 --- a/src/recallSelector.ts +++ b/src/recallSelector.ts @@ -21,7 +21,7 @@ const SELECT_MEMORIES_FORMAT = { } as const export const UNSUPPORTED_RECALL_SELECTOR_CLIENT_MESSAGE = - "opencode-claude-memory LLM recall requires an OpenCode SDK with structured output session.prompt support. Please upgrade OpenCode/@opencode-ai/plugin." + "opencode-claude-memory LLM recall requires an OpenCode SDK session client with create/prompt/delete support." export type SessionClient = { session?: { @@ -93,12 +93,9 @@ function extractSelectedMemories(response: unknown): string[] { export function isSupportedRecallSelectorClient(client: SessionClient | undefined): boolean { const session = client?.session return Boolean( - session?.create && - session?.prompt && - session?.delete && - session.create.length >= 2 && - session.prompt.length >= 2 && - session.delete.length >= 2, + typeof session?.create === "function" && + typeof session.prompt === "function" && + typeof session.delete === "function", ) } @@ -116,9 +113,11 @@ async function createSelectorSession( if (!client.session?.create) return undefined const response = await client.session.create({ - directory, - parentID: parentSessionID, - title: "opencode-memory recall selector", + body: { + parentID: parentSessionID, + title: "opencode-memory recall selector", + }, + query: { directory }, }) return extractSessionID(response) @@ -143,7 +142,11 @@ async function promptSelectorSession( parts: [{ type: "text", text: content }], } - return client.session.prompt({ sessionID, directory, ...body }) + return client.session.prompt({ + path: { id: sessionID }, + query: { directory }, + body, + }) } async function deleteSelectorSession( @@ -154,7 +157,10 @@ async function deleteSelectorSession( if (!client.session?.delete) return try { - await client.session.delete({ sessionID, directory }) + await client.session.delete({ + path: { id: sessionID }, + query: { directory }, + }) } catch { // Best-effort cleanup. A failed selector deletion should not affect recall. } diff --git a/test/index.test.ts b/test/index.test.ts index 5d45926..219a88c 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -67,11 +67,11 @@ function makeCompletedSelectorClient(selections: string[][]) { let sessionCount = 0 return { session: { - async create(_parameters?: unknown, _requestOptions?: unknown) { + async create(_parameters?: unknown) { sessionCount += 1 return { data: { id: `selector-session-${sessionCount}` } } }, - async prompt(_parameters: unknown, _requestOptions?: unknown) { + async prompt(_parameters: unknown) { const selected = selections[promptCount] ?? selections.at(-1) ?? [] promptCount += 1 return { @@ -85,7 +85,7 @@ function makeCompletedSelectorClient(selections: string[][]) { }, } }, - async delete(_parameters: unknown, _requestOptions?: unknown) { + async delete(_parameters: unknown) { return { data: true } }, }, @@ -261,16 +261,22 @@ describe("MemoryPlugin system transform", () => { }) describe("MemoryPlugin LLM recall prefetch", () => { - test("fails fast when recall prefetch receives an unsupported session client", async () => { + test("prefetches without failing user message transform when runtime client uses the default SDK shape", async () => { const repo = makeTempGitRepo() saveMemory(repo, "testing_pref_unsupported", "Testing Preference", "Database integration test guidance", "feedback", "Use real databases in integration tests.") + let createCalled = false const client = { session: { async create(_parameters?: unknown) { + createCalled = true return { data: { id: "selector-session" } } }, async prompt(_parameters: unknown) { - return { data: { parts: [] } } + return { + data: { + parts: [{ text: JSON.stringify({ selected_memories: ["testing_pref_unsupported.md"] }) }], + }, + } }, async delete(_parameters: unknown) { return { data: true } @@ -280,26 +286,28 @@ describe("MemoryPlugin LLM recall prefetch", () => { const plugin = await MemoryPlugin({ worktree: repo, directory: repo, client } as never) const messagesTransform = plugin["experimental.chat.messages.transform"] as unknown as MessagesTransform + const transform = plugin["experimental.chat.system.transform"] as unknown as SystemTransform - let error: unknown - try { - await messagesTransform( - {}, - { - messages: [ - { - info: { role: "user", sessionID: "ses_unsupported_prefetch" }, - parts: [{ type: "text", text: "How should we test database changes?" }], - }, - ], - }, - ) - } catch (caught) { - error = caught - } + await messagesTransform( + {}, + { + messages: [ + { + info: { role: "user", sessionID: "ses_unsupported_prefetch" }, + parts: [{ type: "text", text: "How should we test database changes?" }], + }, + ], + }, + ) + await flushPromises() + + const output = { system: [] as string[] } + await transform({ model: "test-model", sessionID: "ses_unsupported_prefetch" }, output) - expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain("requires an OpenCode SDK with structured output session.prompt support") + expect(createCalled).toBe(true) + expect(output.system[0]).toContain("## MEMORY.md") + expect(output.system[0]).toContain("## Recalled Memories") + expect(output.system[0]).toContain("Testing Preference") }) test("does not wait for an unfinished selector and injects completed recall on the next loop", async () => { @@ -309,13 +317,13 @@ describe("MemoryPlugin LLM recall prefetch", () => { const promptResult = deferred() const client = { session: { - async create(_parameters?: unknown, _requestOptions?: unknown) { + async create(_parameters?: unknown) { return { data: { id: "selector-session" } } }, - async prompt(_parameters: unknown, _requestOptions?: unknown) { + async prompt(_parameters: unknown) { return promptResult.promise }, - async delete(_parameters: unknown, _requestOptions?: unknown) { + async delete(_parameters: unknown) { return { data: true } }, }, diff --git a/test/memory-recall-prefetch-e2e.test.ts b/test/memory-recall-prefetch-e2e.test.ts index c106a11..dd74911 100644 --- a/test/memory-recall-prefetch-e2e.test.ts +++ b/test/memory-recall-prefetch-e2e.test.ts @@ -66,7 +66,7 @@ async function flushPromises(): Promise { } function selectorPromptText(options: unknown): string { - const parts = (options as { parts?: Array<{ text?: string }> }).parts + const parts = (options as { body?: { parts?: Array<{ text?: string }> } }).body?.parts return parts?.[0]?.text ?? "" } @@ -82,11 +82,11 @@ function makeManifestSelectingClient() { calls, client: { session: { - async create(_parameters?: unknown, _requestOptions?: unknown) { + async create(_parameters?: unknown) { calls.create += 1 return { data: { id: `selector-session-${calls.create}` } } }, - async prompt(options: unknown, _requestOptions?: unknown) { + async prompt(options: unknown) { calls.prompt += 1 calls.promptText = selectorPromptText(options) const selected = calls.promptText.includes("database_rules.md") ? ["database_rules.md"] : [] @@ -102,7 +102,7 @@ function makeManifestSelectingClient() { }, } }, - async delete(_parameters: unknown, _requestOptions?: unknown) { + async delete(_parameters: unknown) { calls.delete += 1 return { data: true } }, diff --git a/test/recallSelector.test.ts b/test/recallSelector.test.ts index a3782cb..f26a337 100644 --- a/test/recallSelector.test.ts +++ b/test/recallSelector.test.ts @@ -15,7 +15,7 @@ function header(filename: string, description: string): MemoryHeader { } describe("selectRelevantMemoryFilenames", () => { - test("throws a clear error when the session client does not support v2 structured output", async () => { + test("throws a clear error when the session client cannot create, prompt, and delete sessions", async () => { const calls: string[] = [] const selectorSessionIDs = new Set() const client = { @@ -24,10 +24,6 @@ describe("selectRelevantMemoryFilenames", () => { calls.push("create") return { data: { id: "selector-session" } } }, - async prompt(_options: unknown) { - calls.push("prompt") - return { data: { parts: [] } } - }, async delete(_options: unknown) { calls.push("delete") return { data: true } @@ -52,21 +48,99 @@ describe("selectRelevantMemoryFilenames", () => { } expect(error).toBeInstanceOf(Error) - expect((error as Error).message).toContain("requires an OpenCode SDK with structured output session.prompt support") + expect((error as Error).message).toContain("requires an OpenCode SDK session client with create/prompt/delete support") expect(calls).toEqual([]) expect(selectorSessionIDs.size).toBe(0) }) + test("supports the current default OpenCode SDK session client shape", async () => { + const calls: Array<{ method: string; options: unknown }> = [] + const selectorSessionIDs = new Set() + const client = { + session: { + async create(options: unknown) { + calls.push({ method: "create", options }) + return { data: { id: "selector-session" } } + }, + async prompt(options: unknown) { + calls.push({ method: "prompt", options }) + expect(selectorSessionIDs.has("selector-session")).toBe(true) + return { + data: { + parts: [{ text: JSON.stringify({ selected_memories: ["testing.md", "missing.md"] }) }], + }, + } + }, + async delete(options: unknown) { + calls.push({ method: "delete", options }) + return { data: true } + }, + }, + } + + const selected = await selectRelevantMemoryFilenames({ + client, + directory: "/repo", + parentSessionID: "parent-session", + query: "How should we run database integration tests?", + memories: [ + header("testing.md", "Database integration test guidance"), + header("release.md", "Release process"), + ], + recentTools: ["grep"], + selectorSessionIDs, + agent: "opencode-memory-recall", + }) + + expect(selected).toEqual(["testing.md"]) + expect(selectorSessionIDs.has("selector-session")).toBe(false) + expect(calls.map((c) => c.method)).toEqual(["create", "prompt", "delete"]) + + const createOptions = calls[0]!.options as { + body?: { parentID?: string; title?: string } + query?: { directory?: string } + } + expect(createOptions.body?.parentID).toBe("parent-session") + expect(createOptions.body?.title).toBe("opencode-memory recall selector") + expect(createOptions.query?.directory).toBe("/repo") + + const promptOptions = calls[1]!.options as { + path?: { id?: string } + query?: { directory?: string } + body?: { + agent?: string + system?: string + format?: { type?: string } + parts?: Array<{ text?: string }> + } + } + expect(promptOptions.path?.id).toBe("selector-session") + expect(promptOptions.query?.directory).toBe("/repo") + expect(promptOptions.body?.agent).toBe("opencode-memory-recall") + expect(promptOptions.body?.system).toBe(SELECT_MEMORIES_SYSTEM_PROMPT) + expect(promptOptions.body?.format?.type).toBe("json_schema") + expect(promptOptions.body?.parts?.[0]?.text).toContain("Query: How should we run database integration tests?") + expect(promptOptions.body?.parts?.[0]?.text).toContain("Available memories:") + expect(promptOptions.body?.parts?.[0]?.text).toContain("Recently used tools: grep") + + const deleteOptions = calls[2]!.options as { + path?: { id?: string } + query?: { directory?: string } + } + expect(deleteOptions.path?.id).toBe("selector-session") + expect(deleteOptions.query?.directory).toBe("/repo") + }) + test("asks a temporary child session for structured filenames and deletes it", async () => { const calls: Array<{ method: string; options: unknown }> = [] const selectorSessionIDs = new Set() const client = { session: { - async create(options: unknown, _requestOptions?: unknown) { + async create(options: unknown) { calls.push({ method: "create", options }) return { data: { id: "selector-session" } } }, - async prompt(options: unknown, _requestOptions?: unknown) { + async prompt(options: unknown) { calls.push({ method: "prompt", options }) expect(selectorSessionIDs.has("selector-session")).toBe(true) return { @@ -80,7 +154,7 @@ describe("selectRelevantMemoryFilenames", () => { }, } }, - async delete(options: unknown, _requestOptions?: unknown) { + async delete(options: unknown) { calls.push({ method: "delete", options }) return { data: true } }, @@ -106,33 +180,38 @@ describe("selectRelevantMemoryFilenames", () => { expect(calls.map((c) => c.method)).toEqual(["create", "prompt", "delete"]) const createOptions = calls[0]!.options as { - parentID?: string - title?: string - directory?: string + body?: { parentID?: string; title?: string } + query?: { directory?: string } } - expect(createOptions.parentID).toBe("parent-session") - expect(createOptions.directory).toBe("/repo") + expect(createOptions.body?.parentID).toBe("parent-session") + expect(createOptions.body?.title).toBe("opencode-memory recall selector") + expect(createOptions.query?.directory).toBe("/repo") const promptOptions = calls[1]!.options as { - sessionID?: string - directory?: string - agent?: string - system?: string - format?: { type?: string } - parts?: Array<{ text?: string }> + path?: { id?: string } + query?: { directory?: string } + body?: { + agent?: string + system?: string + format?: { type?: string } + parts?: Array<{ text?: string }> + } + } + expect(promptOptions.path?.id).toBe("selector-session") + expect(promptOptions.query?.directory).toBe("/repo") + expect(promptOptions.body?.agent).toBe("opencode-memory-recall") + expect(promptOptions.body?.system).toBe(SELECT_MEMORIES_SYSTEM_PROMPT) + expect(promptOptions.body?.format?.type).toBe("json_schema") + expect(promptOptions.body?.parts?.[0]?.text).toContain("Query: How should we run database integration tests?") + expect(promptOptions.body?.parts?.[0]?.text).toContain("Available memories:") + expect(promptOptions.body?.parts?.[0]?.text).toContain("Recently used tools: grep") + + const deleteOptions = calls[2]!.options as { + path?: { id?: string } + query?: { directory?: string } } - expect(promptOptions.sessionID).toBe("selector-session") - expect(promptOptions.directory).toBe("/repo") - expect(promptOptions.agent).toBe("opencode-memory-recall") - expect(promptOptions.system).toBe(SELECT_MEMORIES_SYSTEM_PROMPT) - expect(promptOptions.format?.type).toBe("json_schema") - expect(promptOptions.parts?.[0]?.text).toContain("Query: How should we run database integration tests?") - expect(promptOptions.parts?.[0]?.text).toContain("Available memories:") - expect(promptOptions.parts?.[0]?.text).toContain("Recently used tools: grep") - - const deleteOptions = calls[2]!.options as { sessionID?: string; directory?: string } - expect(deleteOptions.sessionID).toBe("selector-session") - expect(deleteOptions.directory).toBe("/repo") + expect(deleteOptions.path?.id).toBe("selector-session") + expect(deleteOptions.query?.directory).toBe("/repo") }) test("calls session methods with their client receiver intact", async () => { @@ -140,10 +219,10 @@ describe("selectRelevantMemoryFilenames", () => { const session = { sessionID: "selector-session", deleted: false, - async create(_parameters?: unknown, _requestOptions?: unknown) { + async create(_parameters?: unknown) { return { data: { id: this.sessionID } } }, - async prompt(_parameters: unknown, _requestOptions?: unknown) { + async prompt(_parameters: unknown) { return { data: { info: { @@ -155,7 +234,7 @@ describe("selectRelevantMemoryFilenames", () => { }, } }, - async delete(_parameters: unknown, _requestOptions?: unknown) { + async delete(_parameters: unknown) { this.deleted = true return { data: true } }, @@ -181,15 +260,15 @@ describe("selectRelevantMemoryFilenames", () => { const selectorSessionIDs = new Set() const client = { session: { - async create(_parameters?: unknown, _requestOptions?: unknown) { + async create(_parameters?: unknown) { calls.push("create") return { data: { id: "selector-session" } } }, - async prompt(_parameters: unknown, _requestOptions?: unknown) { + async prompt(_parameters: unknown) { calls.push("prompt") throw new Error("selector failed") }, - async delete(_parameters: unknown, _requestOptions?: unknown) { + async delete(_parameters: unknown) { calls.push("delete") return { data: true } },