From 5debb9dfee4d700baf06d7560003f14b5a23bc23 Mon Sep 17 00:00:00 2001 From: shuage Date: Sun, 28 Jun 2026 23:28:07 +0800 Subject: [PATCH] fix(auto-capture): reuse injected opencode client --- src/index.ts | 6 +- src/services/ai/opencode-provider.ts | 120 ++++++++++++++++++++++----- tests/opencode-provider.test.ts | 60 ++++++++++++++ 3 files changed, 165 insertions(+), 21 deletions(-) diff --git a/src/index.ts b/src/index.ts index d1419f4..070ed30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,11 +45,13 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { (async () => { try { - const { setConnectedProviders, setV2Client, createV2Client } = + const { createStructuredOutputClient, setConnectedProviders, setV2Client } = await import("./services/ai/opencode-provider.js"); - setV2Client(createV2Client(ctx.serverUrl)); + setConnectedProviders([]); + setV2Client(undefined); const providerResult = await ctx.client.provider.list(); if (providerResult.data?.connected) { + setV2Client(createStructuredOutputClient(ctx.client)); setConnectedProviders(providerResult.data.connected); } } catch (error) { diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index f812212..c308596 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -11,10 +11,42 @@ */ import type { z } from "zod"; -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"; +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; + +type SessionCreateResult = { + data?: { id?: string }; + id?: string; + error?: { message?: string; data?: { message?: string } }; + response?: { status?: number; statusText?: string }; +}; + +type SessionPromptResult = { + data?: { + info?: { + structured?: unknown; + error?: { name: string; data?: { message?: string } }; + }; + }; +}; + +export interface StructuredOutputClient { + session: { + create(input: Record): Promise; + prompt(input: Record): Promise; + delete(input: Record): Promise; + }; +} + +type InjectedOpencodeClient = { + session: { + create(options: unknown): Promise; + prompt(options: unknown): Promise; + delete(options: unknown): Promise; + }; +}; let _connectedProviders: Set = new Set(); -let _v2Client: OpencodeClient | undefined; +let _v2Client: StructuredOutputClient | undefined; export function setConnectedProviders(providers: string[]): void { _connectedProviders = new Set(providers); @@ -24,21 +56,78 @@ export function isProviderConnected(providerName: string): boolean { return _connectedProviders.has(providerName); } -export function setV2Client(client: OpencodeClient): void { +export function setV2Client(client: StructuredOutputClient | undefined): void { _v2Client = client; } -export function getV2Client(): OpencodeClient | undefined { +export function getV2Client(): StructuredOutputClient | undefined { return _v2Client; } -export function createV2Client(serverUrl: URL | string): OpencodeClient { +export function createV2Client(serverUrl: URL | string): StructuredOutputClient { const baseUrl = typeof serverUrl === "string" ? serverUrl : serverUrl.toString(); - return createOpencodeClient({ baseUrl }); + return createOpencodeClient({ baseUrl }) as unknown as StructuredOutputClient; +} + +function compactObject(value: Record): Record { + return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)); +} + +export function createStructuredOutputClient( + client: InjectedOpencodeClient +): StructuredOutputClient { + return { + session: { + create: (input) => + client.session.create({ + query: compactObject({ directory: input.directory, workspace: input.workspace }), + body: compactObject({ + parentID: input.parentID, + title: input.title, + agent: input.agent, + model: input.model, + metadata: input.metadata, + permission: input.permission, + workspaceID: input.workspaceID, + }), + }) as Promise, + prompt: (input) => + client.session.prompt({ + path: { id: input.sessionID }, + query: compactObject({ directory: input.directory, workspace: input.workspace }), + body: compactObject({ + messageID: input.messageID, + model: input.model, + agent: input.agent, + noReply: input.noReply, + tools: input.tools, + format: input.format, + system: input.system, + variant: input.variant, + parts: input.parts, + }), + }) as Promise, + delete: (input) => + client.session.delete({ + path: { id: input.sessionID }, + query: compactObject({ directory: input.directory, workspace: input.workspace }), + }), + }, + }; +} + +function summarizeCreateFailure(created: SessionCreateResult): string { + const status = created?.response?.status; + const statusText = created?.response?.statusText; + const message = created?.error?.message ?? created?.error?.data?.message; + const details = [status ? `status=${status}` : undefined, statusText, message] + .filter(Boolean) + .join(" "); + return details ? ` (${details})` : ""; } export interface StructuredOutputOptions { - client: OpencodeClient; + client: StructuredOutputClient; providerID: string; modelID: string; systemPrompt: string; @@ -72,10 +161,12 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< title: "opencode-mem capture", ...(directory ? { directory } : {}), }); - const sessionID = (created as { data?: { id?: string } })?.data?.id; + const sessionID = created.data?.id ?? created.id; if (!sessionID) { throw new Error( - "opencode-mem: session.create returned no session id; cannot generate structured output" + `opencode-mem: session.create returned no session id${summarizeCreateFailure( + created + )}; cannot generate structured output` ); } @@ -94,16 +185,7 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< noReply: true, }); - const data = ( - promptResult as { - data?: { - info?: { - structured?: unknown; - error?: { name: string; data?: { message?: string } }; - }; - }; - } - ).data; + const data = promptResult.data; const info = data?.info; if (!info) { diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index fc71ab8..dd8c8b5 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { z } from "zod"; import { + createStructuredOutputClient, createV2Client, generateStructuredOutput, getV2Client, @@ -96,6 +97,65 @@ describe("v2 client cache", () => { }); }); +describe("structured output client adapter", () => { + it("uses the injected opencode client instead of opening a new serverUrl client", async () => { + const calls: Array<{ method: string; options: unknown }> = []; + const injectedClient = { + session: { + create: async (options: unknown) => { + calls.push({ method: "create", options }); + return { data: { id: "ses_injected" } }; + }, + prompt: async (options: unknown) => { + calls.push({ method: "prompt", options }); + return { + data: { + info: { structured: { topic: "ctx-client", count: 1 } }, + parts: [], + }, + }; + }, + delete: async (options: unknown) => { + calls.push({ method: "delete", options }); + return { data: true }; + }, + }, + }; + + const result = await generateStructuredOutput({ + client: createStructuredOutputClient(injectedClient), + providerID: "openai", + modelID: "gpt-5.4-mini", + systemPrompt: "system", + userPrompt: "user", + schema, + directory: "/repo", + retryCount: 2, + }); + + expect(result).toEqual({ topic: "ctx-client", count: 1 }); + expect(calls.map((c) => c.method)).toEqual(["create", "prompt", "delete"]); + expect(calls[0]?.options).toEqual({ + query: { directory: "/repo" }, + body: { title: "opencode-mem capture" }, + }); + expect(calls[1]?.options).toMatchObject({ + path: { id: "ses_injected" }, + query: { directory: "/repo" }, + body: { + model: { providerID: "openai", modelID: "gpt-5.4-mini" }, + system: "system", + noReply: true, + format: { type: "json_schema", retryCount: 2 }, + }, + }); + expect(calls[2]?.options).toEqual({ + path: { id: "ses_injected" }, + query: { directory: "/repo" }, + }); + }); +}); + describe("generateStructuredOutput", () => { let mock: ReturnType | undefined;