From 32a77f17126acd06f204018e357a57d7b9f79988 Mon Sep 17 00:00:00 2001 From: Evgenii Peshkov Date: Wed, 8 Jul 2026 10:53:41 +0100 Subject: [PATCH 1/7] fix(ai): route structured output through host fetch Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/index.ts | 26 +++++++++++-- src/services/ai/opencode-host-config.ts | 51 +++++++++++++++++++++++++ tests/plugin-host-config.test.ts | 33 ++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 src/services/ai/opencode-host-config.ts create mode 100644 tests/plugin-host-config.test.ts diff --git a/src/index.ts b/src/index.ts index 07140f2..0e8a421 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,13 @@ 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"; +import { + createV2Client, + setConnectedProviders, + setHostFetch, + setV2Client, +} from "./services/ai/opencode-provider.js"; +import { getHostClientConfig } from "./services/ai/opencode-host-config.js"; function logAutoCaptureProviderStatus(): void { if (!CONFIG.autoCaptureEnabled || CONFIG.autoCaptureProviderStatus.ready) return; @@ -52,11 +59,17 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { })(); } + const hostConfig = getHostClientConfig(ctx); + if (hostConfig.fetch) { + setHostFetch(hostConfig.fetch); + } + const serverUrl = hostConfig.baseUrl ?? ctx.serverUrl; + if (serverUrl) { + setV2Client(createV2Client(serverUrl)); + } + (async () => { try { - const { setConnectedProviders, setV2Client, createV2Client } = - await import("./services/ai/opencode-provider.js"); - setV2Client(createV2Client(ctx.serverUrl)); const providerResult = await ctx.client.provider.list(); if (providerResult.data?.connected) { setConnectedProviders(providerResult.data.connected); @@ -175,6 +188,13 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const userMessage = textParts.map((p) => p.text).join("\n"); if (!userMessage.trim()) return; + if ( + userMessage.includes("Analyze this conversation.") && + userMessage.includes('type="skip"') + ) { + return; + } + userPromptManager.savePrompt(input.sessionID, output.message.id, directory, userMessage); const messagesResponse = await ctx.client.session.messages({ diff --git a/src/services/ai/opencode-host-config.ts b/src/services/ai/opencode-host-config.ts new file mode 100644 index 0000000..abf6869 --- /dev/null +++ b/src/services/ai/opencode-host-config.ts @@ -0,0 +1,51 @@ +export type HostClientConfig = { + readonly baseUrl: string | undefined; + readonly fetch: typeof fetch | undefined; +}; + +export function getHostClientConfig(ctx: { readonly client: unknown }): HostClientConfig { + const client = toRecord(ctx.client); + if (!client) return { baseUrl: undefined, fetch: undefined }; + + const configs = sdkConfigs(client); + const baseUrl = configs.find((config) => typeof config["baseUrl"] === "string")?.["baseUrl"]; + const customFetch = configs.find((config) => isFetch(config["fetch"]))?.["fetch"]; + + return { + baseUrl: typeof baseUrl === "string" ? baseUrl : undefined, + fetch: isFetch(customFetch) ? customFetch : undefined, + }; +} + +function sdkConfigs(client: Record): Record[] { + const configs: Record[] = []; + const nestedClients = Object.values(client).flatMap((value): Record[] => { + const record = toRecord(value); + return record ? [record] : []; + }); + const candidates: Record[] = [client, ...nestedClients]; + + for (const candidate of candidates) { + const sdkClient = toRecord(candidate["_client"]); + const getConfig = sdkClient?.["getConfig"]; + if (!isConfigGetter(getConfig)) continue; + + const config = toRecord(getConfig.call(sdkClient)); + if (config) configs.push(config); + } + + return configs; +} + +function toRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null) return undefined; + return value as Record; +} + +function isConfigGetter(value: unknown): value is (this: unknown) => unknown { + return typeof value === "function"; +} + +function isFetch(value: unknown): value is typeof fetch { + return typeof value === "function"; +} diff --git a/tests/plugin-host-config.test.ts b/tests/plugin-host-config.test.ts new file mode 100644 index 0000000..3c9b564 --- /dev/null +++ b/tests/plugin-host-config.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { getHostClientConfig } from "../src/services/ai/opencode-host-config.js"; + +function sdkService(config: Record): Record { + return { + _client: { + getConfig: () => config, + }, + }; +} + +function pluginInput(client: Record): { + readonly client: Record; +} { + return { + client, + }; +} + +describe("OpenCode host client config", () => { + it("extracts host fetch from nested SDK service clients", () => { + const hostFetch = globalThis.fetch; + const ctx = pluginInput({ + session: sdkService({ baseUrl: "http://localhost:4096", fetch: hostFetch }), + provider: { list: async () => ({ data: { connected: [] } }) }, + }); + + expect(getHostClientConfig(ctx)).toEqual({ + baseUrl: "http://localhost:4096", + fetch: hostFetch, + }); + }); +}); From 9b163d8cad3d41118585f9361048f5447d3808e7 Mon Sep 17 00:00:00 2001 From: Evgenii Peshkov Date: Wed, 8 Jul 2026 10:54:11 +0100 Subject: [PATCH 2/7] fix(ai): harden structured output transport Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/services/ai/opencode-diagnostics.ts | 48 +++++++ src/services/ai/opencode-provider.ts | 123 ++++++++++++----- tests/opencode-provider.test.ts | 172 +++++++++++++++++++++--- 3 files changed, 293 insertions(+), 50 deletions(-) create mode 100644 src/services/ai/opencode-diagnostics.ts diff --git a/src/services/ai/opencode-diagnostics.ts b/src/services/ai/opencode-diagnostics.ts new file mode 100644 index 0000000..abd1482 --- /dev/null +++ b/src/services/ai/opencode-diagnostics.ts @@ -0,0 +1,48 @@ +export interface FetchEndpoint { + readonly label: string; + readonly url: string; +} + +export function diagnosticUrl(url: string): string { + try { + const parsed = new URL(url); + parsed.search = ""; + return parsed.toString(); + } catch { + return url.split("?")[0] ?? url; + } +} + +export function responseStatus(res: Response): string { + const statusTextByCode: Record = { + 500: "Internal Server Error", + 502: "Bad Gateway", + }; + return `${res.status} ${res.statusText || statusTextByCode[res.status] || "Unknown Status"}`; +} + +function redactedBody(text: string): string { + return text ? "" : ""; +} + +export async function readJson(res: Response, endpoint: FetchEndpoint): Promise { + const text = await res.text(); + const url = diagnosticUrl(endpoint.url); + if (!res.ok) { + throw new Error( + `opencode-mem: opencode ${endpoint.label} failed at ${url} (${responseStatus(res)}): ${redactedBody(text)}` + ); + } + if (!text) { + throw new Error( + `opencode-mem: opencode ${endpoint.label} at ${url} returned an empty response body` + ); + } + try { + return JSON.parse(text) as T; + } catch { + throw new Error( + `opencode-mem: opencode ${endpoint.label} at ${url} returned non-JSON body: ${redactedBody(text)}` + ); + } +} diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index 721a1fd..1d086a4 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -25,10 +25,25 @@ import type { z } from "zod"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"; +import { + diagnosticUrl, + readJson, + responseStatus, + type FetchEndpoint, +} from "./opencode-diagnostics.js"; let _connectedProviders: Set = new Set(); let _v2Client: OpencodeClient | undefined; let _v2BaseUrl: string | undefined; +let _hostFetch: typeof fetch | undefined; + +export function setHostFetch(customFetch: typeof fetch): void { + _hostFetch = customFetch; +} + +export function resetHostFetch(): void { + _hostFetch = undefined; +} export function setConnectedProviders(providers: string[]): void { _connectedProviders = new Set(providers); @@ -104,17 +119,19 @@ export async function generateStructuredOutput(opts: StructuredOutputOptions< }); if (info.error) { - const msg = info.error.data?.message ?? info.error.name; - throw new Error(`opencode-mem: opencode reported ${info.error.name}: ${msg}`); + throw new Error( + `opencode-mem: opencode reported ${info.error.name}: ${formatAssistantError(info.error)}` + ); } - if (info.structured === undefined || info.structured === null) { + const structuredOutput = info.structured_output ?? info.structured; + if (structuredOutput === undefined || structuredOutput === null) { throw new Error( - "opencode-mem: opencode returned no structured output (info.structured was empty)" + "opencode-mem: opencode returned no structured output (info.structured_output/info.structured were empty)" ); } - return schema.parse(info.structured); + return schema.parse(structuredOutput); } finally { // Best-effort: leaving a transient session behind is cosmetic, not // worth failing a successful capture if cleanup itself errors. @@ -135,33 +152,30 @@ function buildQuery(directory?: string): string { return `?directory=${encodeURIComponent(directory)}`; } -async function readJson(res: Response, context: string): Promise { - const text = await res.text(); - if (!res.ok) { - throw new Error( - `opencode-mem: opencode ${context} failed (${res.status} ${res.statusText}): ${text || ""}` - ); - } - if (!text) { - throw new Error(`opencode-mem: opencode ${context} returned an empty response body`); - } +async function fetchJson(endpoint: FetchEndpoint, init: RequestInit): Promise { + let res: Response; try { - return JSON.parse(text) as T; - } catch (err) { + res = await activeFetch()(new Request(endpoint.url, init)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); throw new Error( - `opencode-mem: opencode ${context} returned non-JSON body: ${text.slice(0, 200)}` + `opencode-mem: failed to fetch ${endpoint.label} at ${diagnosticUrl(endpoint.url)}: ${message}` ); } + + return readJson(res, endpoint); } async function createSession(base: string, directory?: string): Promise { const url = `${base}/session${buildQuery(directory)}`; - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: "opencode-mem capture" }), - }); - const body = await readJson<{ id?: string }>(res, "POST /session"); + const body = await fetchJson<{ id?: string }>( + { label: "POST /session", url }, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "opencode-mem capture" }), + } + ); if (!body.id) { throw new Error( "opencode-mem: session.create returned no session id; cannot generate structured output" @@ -183,7 +197,33 @@ interface PromptSessionArgs { interface AssistantInfo { structured?: unknown; - error?: { name: string; data?: { message?: string } }; + structured_output?: unknown; + error?: { name: string; data?: { message?: string; [key: string]: unknown } }; +} + +function formatAssistantError(error: NonNullable): string { + if (!error.data) return error.name; + + const details = safeAssistantErrorDetails(error.data); + if (!error.data.message) return details; + + return details ? `${error.data.message}; ${details}` : error.data.message; +} + +function safeAssistantErrorDetails( + data: NonNullable["data"]> +): string { + const safeFields: Record = {}; + for (const key of ["statusCode", "providerID", "modelID"] as const) { + const value = data[key]; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + safeFields[key] = value; + } + } + + const entries = Object.entries(safeFields); + if (entries.length === 0) return ""; + return `details=${JSON.stringify(Object.fromEntries(entries))}`; } interface MessageV2WithParts { @@ -202,14 +242,15 @@ async function promptSession(base: string, args: PromptSessionArgs): Promise(res, "POST /session/{id}/message"); + const data = await fetchJson( + { label: "POST /session/{id}/message", url }, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ); if (!data.info) { throw new Error("opencode-mem: prompt response missing `info`"); } @@ -218,10 +259,24 @@ async function promptSession(base: string, args: PromptSessionArgs): Promise { const url = `${base}/session/${encodeURIComponent(sessionID)}${buildQuery(directory)}`; - const res = await fetch(url, { method: "DELETE" }); + let res: Response; + try { + res = await activeFetch()(new Request(url, { method: "DELETE" })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `opencode-mem: failed to fetch DELETE /session/{id} at ${diagnosticUrl(url)}: ${message}` + ); + } // DELETE /session/:id returns boolean. We only care that it ran; failures // are swallowed at the call site. if (!res.ok) { - throw new Error(`delete failed: ${res.status} ${res.statusText}`); + throw new Error( + `opencode-mem: opencode DELETE /session/{id} failed at ${diagnosticUrl(url)} (${responseStatus(res)})` + ); } } + +function activeFetch(): typeof fetch { + return _hostFetch ?? globalThis.fetch; +} diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index 7132b36..87278bd 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -5,7 +5,9 @@ import { generateStructuredOutput, getV2Client, isProviderConnected, + resetHostFetch, setConnectedProviders, + setHostFetch, setV2Client, } from "../src/services/ai/opencode-provider.js"; @@ -101,10 +103,12 @@ describe("generateStructuredOutput", () => { beforeEach(() => { mock = undefined; + resetHostFetch(); }); afterEach(() => { mock?.restore(); + resetHostFetch(); }); it("posts schema to session.prompt and returns parsed structured output", async () => { @@ -115,7 +119,7 @@ describe("generateStructuredOutput", () => { if (call.method === "POST" && call.url.includes("/session/ses_test_1/message")) { return { body: { - info: { structured: { topic: "auth", count: 3 } }, + info: { structured_output: { topic: "auth", count: 3 } }, parts: [], }, }; @@ -146,7 +150,7 @@ describe("generateStructuredOutput", () => { modelID: "gpt-4o-mini", }); expect(promptBody.system).toBe("system"); - expect(promptBody.noReply).toBe(true); + expect(promptBody).not.toHaveProperty("noReply"); const format = promptBody.format as Record; expect(format.type).toBe("json_schema"); expect(format.schema).toBeDefined(); @@ -156,7 +160,7 @@ describe("generateStructuredOutput", () => { expect(deleteCall!.url.endsWith("/session/ses_test_1")).toBe(true); }); - it("rejects when info.error is present (StructuredOutputError)", async () => { + it("rejects with full info.error details when opencode reports an assistant error", async () => { mock = installFetchMock((call) => { if (call.method === "POST" && call.url.endsWith("/session")) { return { body: { id: "ses_err" } }; @@ -167,7 +171,12 @@ describe("generateStructuredOutput", () => { info: { error: { name: "StructuredOutputError", - data: { message: "schema validation failed" }, + data: { + message: "schema validation failed", + statusCode: 400, + providerID: "anthropic", + prompt: "secret prompt fragment", + }, }, }, parts: [], @@ -190,7 +199,20 @@ describe("generateStructuredOutput", () => { userPrompt: "u", schema, }) - ).rejects.toThrow(/StructuredOutputError/); + ).rejects.toThrow( + /StructuredOutputError: schema validation failed; details=.*"statusCode":400.*"providerID":"anthropic"/ + ); + + await generateStructuredOutput({ + client, + providerID: "anthropic", + modelID: "claude-haiku-4-5", + systemPrompt: "s", + userPrompt: "u", + schema, + }).catch((error: unknown) => { + expect(String(error)).not.toContain("secret prompt fragment"); + }); expect(mock.calls.find((c) => c.method === "DELETE")).toBeDefined(); }); @@ -251,7 +273,7 @@ describe("generateStructuredOutput", () => { if (call.method === "POST" && call.url.includes("/session/ses_delfail/message")) { return { body: { - info: { structured: { topic: "x", count: 1 } }, + info: { structured_output: { topic: "x", count: 1 } }, parts: [], }, }; @@ -321,7 +343,7 @@ describe("generateStructuredOutput", () => { if (call.method === "POST" && call.url.includes("/session/ses_retry/message")) { return { body: { - info: { structured: { topic: "x", count: 1 } }, + info: { structured_output: { topic: "x", count: 1 } }, parts: [], }, }; @@ -356,10 +378,12 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { beforeEach(() => { mock = undefined; + resetHostFetch(); }); afterEach(() => { mock?.restore(); + resetHostFetch(); }); it("forwards directory as query param on create/prompt/delete", async () => { @@ -374,7 +398,7 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { if (call.method === "POST" && call.url.includes("/session/ses_dir/message")) { return { body: { - info: { structured: { topic: "auth", count: 1 } }, + info: { structured_output: { topic: "auth", count: 1 } }, parts: [], }, }; @@ -430,7 +454,7 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { if (call.method === "POST" && call.url.includes("/session/ses_nodir/message")) { return { body: { - info: { structured: { topic: "x", count: 1 } }, + info: { structured_output: { topic: "x", count: 1 } }, parts: [], }, }; @@ -469,7 +493,7 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { if (call.method === "POST" && call.url.includes("/session/ses_slash/message")) { return { body: { - info: { structured: { topic: "x", count: 1 } }, + info: { structured_output: { topic: "x", count: 1 } }, parts: [], }, }; @@ -509,7 +533,7 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { if (call.method === "POST" && call.url.includes("/session/ses_url/message")) { return { body: { - info: { structured: { topic: "x", count: 1 } }, + info: { structured_output: { topic: "x", count: 1 } }, parts: [], }, }; @@ -533,8 +557,106 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { expect(mock.calls.length).toBe(3); }); + it("uses the injected opencode host fetch instead of global fetch", async () => { + const globalFetch = globalThis.fetch; + const calls: FetchCall[] = []; + + globalThis.fetch = (async () => { + throw new TypeError("global fetch should not be used"); + }) as typeof fetch; + + setHostFetch(async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init); + const method = req.method.toUpperCase(); + const text = method === "GET" || method === "HEAD" ? "" : await req.text(); + const call: FetchCall = { + url: req.url, + method, + body: text ? JSON.parse(text) : undefined, + }; + calls.push(call); + + if (call.method === "POST" && call.url.endsWith("/session")) { + return new Response(JSON.stringify({ id: "ses_host_fetch" })); + } + if (call.method === "POST" && call.url.includes("/session/ses_host_fetch/message")) { + return new Response( + JSON.stringify({ info: { structured_output: { topic: "host", count: 1 } }, parts: [] }) + ); + } + if (call.method === "DELETE") { + return new Response(JSON.stringify(true)); + } + throw new Error(`unexpected host fetch: ${call.method} ${call.url}`); + }); + + try { + const client = createV2Client("http://localhost:4096"); + const result = await generateStructuredOutput({ + client, + providerID: "openai", + modelID: "gpt-5.5", + systemPrompt: "s", + userPrompt: "u", + schema, + }); + + expect(result).toEqual({ topic: "host", count: 1 }); + expect(calls.map((call) => call.method)).toEqual(["POST", "POST", "DELETE"]); + } finally { + globalThis.fetch = globalFetch; + resetHostFetch(); + } + }); + it("surfaces a non-2xx response from POST /session", async () => { - mock = installFetchMock(() => ({ status: 502, body: { error: "bad gateway" } })); + mock = installFetchMock(() => ({ + status: 502, + body: { error: "bad gateway", token: "secret-token" }, + })); + + const client = createV2Client("http://127.0.0.1:9999"); + const promise = generateStructuredOutput({ + client, + providerID: "github-copilot", + modelID: "gpt-4o-mini", + systemPrompt: "s", + userPrompt: "u", + schema, + }); + + await expect(promise).rejects.toThrow( + /POST \/session failed at http:\/\/127\.0\.0\.1:9999\/session \(502 Bad Gateway\): / + ); + await promise.catch((error: unknown) => { + expect(String(error)).not.toContain("secret-token"); + expect(String(error)).not.toContain("bad gateway"); + }); + + // No further calls should have been made (no prompt, no delete) + expect(mock!.calls.length).toBe(1); + }); + + it("redacts directory query params and response bodies from prompt failure diagnostics", async () => { + mock = installFetchMock((call) => { + if ( + call.method === "POST" && + call.url.includes("/session") && + !call.url.includes("/message") + ) { + return { body: { id: "ses_redact" } }; + } + if (call.method === "POST" && call.url.includes("/session/ses_redact/message")) { + return { + status: 500, + body: { error: "model unavailable", prompt: "private prompt", path: "/private/project" }, + }; + } + if (call.method === "DELETE") { + return { body: true }; + } + throw new Error(`unexpected fetch: ${call.method} ${call.url}`); + }); const client = createV2Client("http://127.0.0.1:9999"); await expect( @@ -545,11 +667,27 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { systemPrompt: "s", userPrompt: "u", schema, + directory: "/private/project", }) - ).rejects.toThrow(/POST \/session failed/); + ).rejects.toThrow( + /POST \/session\/\{id\}\/message failed at http:\/\/127\.0\.0\.1:9999\/session\/ses_redact\/message \(500 Internal Server Error\): / + ); - // No further calls should have been made (no prompt, no delete) - expect(mock!.calls.length).toBe(1); + await generateStructuredOutput({ + client, + providerID: "github-copilot", + modelID: "gpt-4o-mini", + systemPrompt: "s", + userPrompt: "u", + schema, + directory: "/private/project", + }).catch((error: unknown) => { + const message = String(error); + expect(message).not.toContain("directory="); + expect(message).not.toContain("/private/project"); + expect(message).not.toContain("private prompt"); + expect(message).not.toContain("model unavailable"); + }); }); it("surfaces a non-2xx response from POST /session/{id}/message and still cleans up", async () => { @@ -580,7 +718,9 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { userPrompt: "u", schema, }) - ).rejects.toThrow(/POST \/session\/\{id\}\/message failed/); + ).rejects.toThrow( + /POST \/session\/\{id\}\/message failed at http:\/\/127\.0\.0\.1:9999\/session\/ses_prompt_500\/message \(500 Internal Server Error\): / + ); // delete is best-effort and should still run expect(mock!.calls.find((c) => c.method === "DELETE")).toBeDefined(); From fa691472fadba8064654a711f6f63fd18543df32 Mon Sep 17 00:00:00 2001 From: Evgenii Peshkov Date: Wed, 8 Jul 2026 10:54:38 +0100 Subject: [PATCH 3/7] fix(auto-capture): log capture outcomes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/services/auto-capture.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/services/auto-capture.ts b/src/services/auto-capture.ts index 27802f7..afca178 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -83,9 +83,20 @@ export async function performAutoCapture( latestMemory ); - const summaryResult = await generateSummary(context, sessionID, prompt.content); + let summaryResult: { summary: string; type: string; tags: string[] } | null; + try { + summaryResult = await generateSummary(context, sessionID, prompt.content); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Summary generation failed: ${message}`); + } if (!summaryResult || summaryResult.type === "skip") { + log("Auto-capture skipped", { + promptId: prompt.id, + sessionID, + type: summaryResult?.type, + }); userPromptManager.deletePrompt(prompt.id); claimedPromptId = null; return; @@ -115,6 +126,11 @@ export async function performAutoCapture( userPromptManager.linkMemoryToPrompt(prompt.id, result.id); userPromptManager.markAsCaptured(prompt.id); claimedPromptId = null; + log("Auto-capture memory persisted", { + promptId: prompt.id, + sessionID, + memoryId: result.id, + }); if (CONFIG.showAutoCaptureToasts) { await ctx.client?.tui @@ -130,7 +146,7 @@ export async function performAutoCapture( } return; } else { - throw new Error(result.error || "Database persistence failed"); + throw new Error(`Memory persistence failed: ${result.error || "database write failed"}`); } } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); From 6babecef55cf565e3e7ece299491334df9134809 Mon Sep 17 00:00:00 2001 From: Evgenii Peshkov Date: Wed, 8 Jul 2026 10:55:05 +0100 Subject: [PATCH 4/7] chore(build): clean dist before building Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0e4e9a7..c3ac42c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ } }, "scripts": { - "build": "bunx tsc && bun scripts/copy-web-assets.mjs", + "build": "rm -rf dist && bunx tsc && bun scripts/copy-web-assets.mjs", "dev": "tsc --watch", "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.{ts,js,css,html}\"", From 7aeb3b3a984a6e3ebf87f6f6288a5a1470baabaf Mon Sep 17 00:00:00 2001 From: Evgenii Peshkov Date: Wed, 8 Jul 2026 10:56:28 +0100 Subject: [PATCH 5/7] chore(plugin): remove unused tool context Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/index.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0e8a421..ec01afb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -294,19 +294,16 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { limit: tool.schema.number().optional(), scope: tool.schema.enum(["project", "all-projects"]).optional(), }, - async execute( - args: { - mode?: "add" | "search" | "profile" | "list" | "forget" | "help"; - content?: string; - query?: string; - tags?: string; - type?: MemoryType; - memoryId?: string; - limit?: number; - scope?: MemoryScope; - }, - toolCtx: { sessionID: string } - ) { + async execute(args: { + mode?: "add" | "search" | "profile" | "list" | "forget" | "help"; + content?: string; + query?: string; + tags?: string; + type?: MemoryType; + memoryId?: string; + limit?: number; + scope?: MemoryScope; + }) { if (!isConfigured()) { return JSON.stringify({ success: false, From 56b7086570a4186a15b35414aba0391940e80efa Mon Sep 17 00:00:00 2001 From: Evgenii Peshkov Date: Wed, 8 Jul 2026 10:58:03 +0100 Subject: [PATCH 6/7] test(ai): type host fetch mocks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/opencode-provider.test.ts | 63 +++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index 87278bd..30c310a 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -561,34 +561,43 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { const globalFetch = globalThis.fetch; const calls: FetchCall[] = []; - globalThis.fetch = (async () => { - throw new TypeError("global fetch should not be used"); - }) as typeof fetch; - - setHostFetch(async (input: RequestInfo | URL, init?: RequestInit) => { - const req = input instanceof Request ? input : new Request(input, init); - const method = req.method.toUpperCase(); - const text = method === "GET" || method === "HEAD" ? "" : await req.text(); - const call: FetchCall = { - url: req.url, - method, - body: text ? JSON.parse(text) : undefined, - }; - calls.push(call); + const failingFetch: typeof fetch = Object.assign( + async () => { + throw new TypeError("global fetch should not be used"); + }, + { preconnect: globalFetch.preconnect } + ); + globalThis.fetch = failingFetch; + + const hostFetch: typeof fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init); + const method = req.method.toUpperCase(); + const text = method === "GET" || method === "HEAD" ? "" : await req.text(); + const call: FetchCall = { + url: req.url, + method, + body: text ? JSON.parse(text) : undefined, + }; + calls.push(call); + + if (call.method === "POST" && call.url.endsWith("/session")) { + return new Response(JSON.stringify({ id: "ses_host_fetch" })); + } + if (call.method === "POST" && call.url.includes("/session/ses_host_fetch/message")) { + return new Response( + JSON.stringify({ info: { structured_output: { topic: "host", count: 1 } }, parts: [] }) + ); + } + if (call.method === "DELETE") { + return new Response(JSON.stringify(true)); + } + throw new Error(`unexpected host fetch: ${call.method} ${call.url}`); + }, + { preconnect: globalFetch.preconnect } + ); - if (call.method === "POST" && call.url.endsWith("/session")) { - return new Response(JSON.stringify({ id: "ses_host_fetch" })); - } - if (call.method === "POST" && call.url.includes("/session/ses_host_fetch/message")) { - return new Response( - JSON.stringify({ info: { structured_output: { topic: "host", count: 1 } }, parts: [] }) - ); - } - if (call.method === "DELETE") { - return new Response(JSON.stringify(true)); - } - throw new Error(`unexpected host fetch: ${call.method} ${call.url}`); - }); + setHostFetch(hostFetch); try { const client = createV2Client("http://localhost:4096"); From b7bc0f2b2506c6de1643ed0579af1b9fe3ec0fd3 Mon Sep 17 00:00:00 2001 From: Evgenii Peshkov Date: Wed, 8 Jul 2026 11:23:51 +0100 Subject: [PATCH 7/7] fix(ai): address host transport review feedback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/index.ts | 44 ++++++++---- src/services/ai/opencode-host-config.ts | 8 ++- src/services/ai/opencode-provider.ts | 3 + tests/opencode-provider.test.ts | 2 +- tests/plugin-host-config.test.ts | 92 +++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index ec01afb..29bcb7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,12 +18,42 @@ import { getLanguageName } from "./services/language-detector.js"; import type { MemoryScope } from "./services/client.js"; import { createV2Client, + resetHostFetch, setConnectedProviders, setHostFetch, setV2Client, } from "./services/ai/opencode-provider.js"; import { getHostClientConfig } from "./services/ai/opencode-host-config.js"; +export function isStructuredSummaryPromptMessage(userMessage: string): boolean { + // This is the plugin's own structured-summary request. OpenCode echoes it + // through chat.message like a normal user message, but capturing it would + // create self-referential memories about the memory prompt instead of the + // user's conversation. + return userMessage.includes("Analyze this conversation.") && userMessage.includes('type="skip"'); +} + +export function configureOpencodeHostTransport(ctx: { + readonly client: unknown; + readonly serverUrl?: string | URL; +}): void { + resetHostFetch(); + const hostConfig = getHostClientConfig(ctx); + if (hostConfig.fetch) { + setHostFetch(hostConfig.fetch); + } else { + log("OpenCode host fetch unavailable; falling back to global fetch", { + clientKeys: hostConfig.clientKeys, + sdkConfigCount: hostConfig.sdkConfigCount, + }); + } + + const serverUrl = hostConfig.baseUrl ?? ctx.serverUrl; + if (serverUrl) { + setV2Client(createV2Client(serverUrl)); + } +} + function logAutoCaptureProviderStatus(): void { if (!CONFIG.autoCaptureEnabled || CONFIG.autoCaptureProviderStatus.ready) return; @@ -59,14 +89,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { })(); } - const hostConfig = getHostClientConfig(ctx); - if (hostConfig.fetch) { - setHostFetch(hostConfig.fetch); - } - const serverUrl = hostConfig.baseUrl ?? ctx.serverUrl; - if (serverUrl) { - setV2Client(createV2Client(serverUrl)); - } + configureOpencodeHostTransport(ctx); (async () => { try { @@ -188,10 +211,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const userMessage = textParts.map((p) => p.text).join("\n"); if (!userMessage.trim()) return; - if ( - userMessage.includes("Analyze this conversation.") && - userMessage.includes('type="skip"') - ) { + if (isStructuredSummaryPromptMessage(userMessage)) { return; } diff --git a/src/services/ai/opencode-host-config.ts b/src/services/ai/opencode-host-config.ts index abf6869..05f6b91 100644 --- a/src/services/ai/opencode-host-config.ts +++ b/src/services/ai/opencode-host-config.ts @@ -1,11 +1,15 @@ export type HostClientConfig = { readonly baseUrl: string | undefined; readonly fetch: typeof fetch | undefined; + readonly clientKeys: readonly string[]; + readonly sdkConfigCount: number; }; export function getHostClientConfig(ctx: { readonly client: unknown }): HostClientConfig { const client = toRecord(ctx.client); - if (!client) return { baseUrl: undefined, fetch: undefined }; + if (!client) { + return { baseUrl: undefined, fetch: undefined, clientKeys: [], sdkConfigCount: 0 }; + } const configs = sdkConfigs(client); const baseUrl = configs.find((config) => typeof config["baseUrl"] === "string")?.["baseUrl"]; @@ -14,6 +18,8 @@ export function getHostClientConfig(ctx: { readonly client: unknown }): HostClie return { baseUrl: typeof baseUrl === "string" ? baseUrl : undefined, fetch: isFetch(customFetch) ? customFetch : undefined, + clientKeys: Object.keys(client), + sdkConfigCount: configs.length, }; } diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index 1d086a4..28ba55e 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -237,6 +237,9 @@ async function promptSession(base: string, args: PromptSessionArgs): Promise { resetHostFetch(); }); - it("posts schema to session.prompt and returns parsed structured output", async () => { + it("posts schema without noReply and returns parsed structured output", async () => { mock = installFetchMock((call) => { if (call.method === "POST" && call.url.endsWith("/session")) { return { body: { id: "ses_test_1" } }; diff --git a/tests/plugin-host-config.test.ts b/tests/plugin-host-config.test.ts index 3c9b564..cd8e698 100644 --- a/tests/plugin-host-config.test.ts +++ b/tests/plugin-host-config.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from "bun:test"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { configureOpencodeHostTransport, isStructuredSummaryPromptMessage } from "../src/index.js"; import { getHostClientConfig } from "../src/services/ai/opencode-host-config.js"; +import { + createV2Client, + generateStructuredOutput, + resetHostFetch, + setHostFetch, +} from "../src/services/ai/opencode-provider.js"; +import { z } from "zod"; function sdkService(config: Record): Record { return { @@ -28,6 +39,87 @@ describe("OpenCode host client config", () => { expect(getHostClientConfig(ctx)).toEqual({ baseUrl: "http://localhost:4096", fetch: hostFetch, + clientKeys: ["session", "provider"], + sdkConfigCount: 1, }); }); + + it("resets stale host fetch and logs when SDK config reflection finds no host fetch", async () => { + const globalFetch = globalThis.fetch; + const logFile = join(mkdtempSync(join(tmpdir(), "opencode-mem-test-")), "opencode-mem.log"); + process.env.OPENCODE_MEM_LOG_FILE = logFile; + const calls: string[] = []; + + const staleHostFetch: typeof fetch = Object.assign( + async () => { + throw new TypeError("stale host fetch should not be used"); + }, + { preconnect: globalFetch.preconnect } + ); + const fallbackFetch: typeof fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init); + calls.push(`${req.method.toUpperCase()} ${req.url}`); + + if (req.method === "POST" && req.url.endsWith("/session")) { + return new Response(JSON.stringify({ id: "ses_global_fetch" })); + } + if (req.method === "POST" && req.url.includes("/session/ses_global_fetch/message")) { + return new Response( + JSON.stringify({ + info: { structured_output: { topic: "fallback", count: 1 } }, + parts: [], + }) + ); + } + return new Response(JSON.stringify(true)); + }, + { preconnect: globalFetch.preconnect } + ); + + setHostFetch(staleHostFetch); + globalThis.fetch = fallbackFetch; + + try { + configureOpencodeHostTransport({ + client: { provider: { list: async () => ({ data: { connected: [] } }) } }, + serverUrl: "http://localhost:4096", + }); + + const result = await generateStructuredOutput({ + client: createV2Client("http://localhost:4096"), + providerID: "openai", + modelID: "gpt-5.5", + systemPrompt: "s", + userPrompt: "u", + schema: z.object({ topic: z.string(), count: z.number() }), + }); + + expect(result).toEqual({ topic: "fallback", count: 1 }); + expect(calls.map((call) => call.split(" ")[0])).toEqual(["POST", "POST", "DELETE"]); + expect(readFileSync(logFile, "utf-8")).toContain( + "OpenCode host fetch unavailable; falling back to global fetch" + ); + } finally { + globalThis.fetch = globalFetch; + resetHostFetch(); + delete process.env.OPENCODE_MEM_LOG_FILE; + } + }); +}); + +describe("structured summary prompt filter", () => { + it("identifies the plugin's own structured-summary prompt echo", () => { + expect( + isStructuredSummaryPromptMessage( + 'Analyze this conversation. Return type="skip" for no memory.' + ) + ).toBe(true); + }); + + it("does not filter ordinary user messages", () => { + expect(isStructuredSummaryPromptMessage("Analyze this conversation in the bug report.")).toBe( + false + ); + }); });