From 6e434b944a4efdf904cfde866ce652cc8c0c6c90 Mon Sep 17 00:00:00 2001 From: Lindy Xu Date: Mon, 24 Aug 2026 13:32:14 +0800 Subject: [PATCH] fix: preserve provider errors in user profile learning When the opencode provider path throws and no manual external API fallback is configured, surface the original provider error instead of masking it with a generic 'External API not configured' message. This mirrors the same fix applied to auto-capture in #258. Closes #265 --- src/services/user-memory-learning.ts | 5 + tests/user-profile-learning-error.test.ts | 155 ++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 tests/user-profile-learning-error.test.ts diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index eebbe62..df8c206 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -632,6 +632,7 @@ async function analyzeUserProfile( existingProfile: UserProfile | null ): Promise { log("user-profile-learning: analyze called", { hasProfile: !!existingProfile }); + let opencodeProviderError: unknown; if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { log("user-profile-learning: trying opencode provider"); try { @@ -694,6 +695,7 @@ Use the update_user_profile tool to save the ${existingProfile ? "updated" : "ne } return { raw: rawData, merged: null }; } catch (e) { + opencodeProviderError = e; log("user-profile-learning: opencode provider failed, falling back to external API", { error: String(e), }); @@ -701,6 +703,9 @@ Use the update_user_profile tool to save the ${existingProfile ? "updated" : "ne } if (!CONFIG.memoryModel || !CONFIG.memoryApiUrl) { + if (opencodeProviderError) { + throw opencodeProviderError; + } log("User Profile Config Check Failed:", { memoryModel: CONFIG.memoryModel, memoryApiUrl: CONFIG.memoryApiUrl, diff --git a/tests/user-profile-learning-error.test.ts b/tests/user-profile-learning-error.test.ts new file mode 100644 index 0000000..1c020eb --- /dev/null +++ b/tests/user-profile-learning-error.test.ts @@ -0,0 +1,155 @@ +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 tempDirs: string[] = []; + +const learningUrl = new URL( + "../src/services/user-memory-learning.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 promptManagerUrl = new URL( + "../src/services/user-prompt/user-prompt-manager.js", + import.meta.url +).href; +const profileManagerUrl = new URL( + "../src/services/user-profile/user-profile-manager.js", + import.meta.url +).href; +const opencodeProviderLoaderUrl = new URL( + "../src/services/ai/opencode-provider-loader.js", + import.meta.url +).href; +const profileLlmClientUrl = new URL( + "../src/services/ai/profile-llm-client.js", + import.meta.url +).href; +const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; + +function runProviderFailureScenario() { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-profile-error-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + const script = ` +import { mock } from "bun:test"; + +const prompts = Array.from({ length: 10 }, (_, i) => ({ + id: \`prompt-\${i}\`, + sessionId: "session-1", + messageId: \`msg-\${i}\`, + projectPath: "/workspace", + content: \`Implement feature \${i}\`, + createdAt: i + 1, + captured: false, + user_learning_captured: false, + capture_attempts: 0, +})); + +mock.module(${JSON.stringify(configUrl)}, () => ({ + CONFIG: { + autoCaptureProviderStatus: { ready: true, mode: "opencode", issues: [] }, + userProfileAnalysisInterval: 5, + opencodeProvider: "opencode-go", + opencodeModel: "deepseek-v4-flash", + showUserProfileToasts: false, + }, +})); + +mock.module(${JSON.stringify(tagsUrl)}, () => ({ + getTags: () => ({ + user: { + tag: "opencode_user_test", + displayName: "Test User", + userName: "tester", + userEmail: "test@example.com", + }, + }), +})); + +mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ + userPromptManager: { + countUnanalyzedForUserLearning: async () => 10, + getPromptsForUserLearning: async () => prompts, + markMultipleAsUserLearningCaptured: async () => {}, + }, +})); + +mock.module(${JSON.stringify(profileManagerUrl)}, () => ({ + userProfileManager: { + getActiveProfile: async () => null, + createProfile: async () => ({}), + mergeProfileData: async () => ({}), + updateProfile: async () => true, + decayInMemory: (d) => ({ data: d }), + syncConfidence: () => {}, + }, +})); + +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); + +mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ + loadOpencodeProvider: async () => ({ + generateStructuredOutput: async () => { + throw new Error( + "opencode-mem: opencode reported APIError: Thinking mode does not support this tool_choice" + ); + }, + }), +})); + +mock.module(${JSON.stringify(profileLlmClientUrl)}, () => ({ + getOpenCodeClient: async () => ({}), +})); + +try { + const { performUserProfileLearning } = await import(${JSON.stringify(learningUrl)}); + await performUserProfileLearning({}, "/workspace"); + console.log(JSON.stringify({ error: null })); +} catch (e) { + console.log(JSON.stringify({ error: e?.message ?? String(e) })); +} +process.exit(0); +`; + + writeFileSync(scriptPath, script, "utf-8"); + 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(); + const jsonLine = stdout + .split("\n") + .reverse() + .find((line) => line.trim().startsWith("{")); + + return { + exitCode: result.exitCode, + stderr, + parsed: jsonLine ? JSON.parse(jsonLine) : null, + }; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("user profile learning error propagation (#265)", () => { + it("preserves the opencode provider error when no manual fallback is configured", () => { + const result = runProviderFailureScenario(); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.error).toContain("Thinking mode does not support"); + expect(result.parsed?.error).not.toContain( + "External API not configured" + ); + }); +});