diff --git a/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index fbbf969..a7aff30 100644 --- a/src/services/user-profile/ai-cleanup.ts +++ b/src/services/user-profile/ai-cleanup.ts @@ -189,20 +189,16 @@ function formatForAI(item: IndexedProfileItem): Record { async function callAICleanup( prompt: string ): Promise<{ profile: IndexedProfile; mapping: AIMapping }> { - // Use opencode internal session when opencodeProvider is configured (same pattern as auto-capture) + // Use opencode internal session when opencodeProvider is configured (same pattern as auto-capture). + // When the client is available, surface OpenCode errors instead of masking them as + // "No AI provider configured" via a silent fallback (#177). if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { - try { - const { getV2Client } = await loadOpencodeProvider(); - const v2Client = getV2Client(); - if (v2Client) { - const result = await callViaOpencodeWithClient(v2Client, prompt); - if (result) return result; - } - } catch (e) { - log("AI cleanup: opencode session failed, falling back to external API", { - error: String(e), - }); + const { getV2Client } = await loadOpencodeProvider(); + const v2Client = getV2Client(); + if (v2Client) { + return callViaOpencodeWithClient(v2Client, prompt); } + log("AI cleanup: opencode client unavailable, falling back to external API"); } if (CONFIG.memoryModel && CONFIG.memoryApiUrl) { @@ -259,6 +255,48 @@ async function callViaExternalAPI( }; } +/** Prompt timeout for profile cleanup sessions (large profiles can exceed 2 minutes). */ +const OPENCODE_CLEANUP_TIMEOUT_MS = 300000; + +type PromptPart = { type?: string; text?: string }; +type PromptInfo = { + error?: { name: string; data?: { message?: string } }; +}; +type PromptResultShape = { + data?: { info?: PromptInfo; parts?: PromptPart[] }; + info?: PromptInfo; + parts?: PromptPart[]; +}; + +/** + * Extract assistant text from an OpenCode session.prompt result. + * AssistantMessage has no `text` field; content lives in `parts` (#177). + */ +export function extractTextFromPromptResult(promptResult: unknown): { + info: PromptInfo | undefined; + rawText: string; +} { + const result = promptResult as PromptResultShape; + const info = result?.data?.info ?? result?.info; + const parts = result?.data?.parts ?? result?.parts ?? []; + const rawText = parts + .filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim(); + return { info, rawText }; +} + +function raceWithTimeout(promise: Promise, ms: number, message: string): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +} + async function callViaOpencodeWithClient( v2Client: any, prompt: string @@ -267,15 +305,14 @@ async function callViaOpencodeWithClient( const systemPrompt = "You are a user profile cleanup assistant. Merge duplicate entries and return only JSON without markdown wrapping."; - const created = (await Promise.race([ + const created = (await raceWithTimeout( v2Client.session.create({ title: "opencode-mem profile cleanup", directory: process.cwd(), }), - new Promise((_, reject) => - setTimeout(() => reject(new Error("session.create timeout")), 30000) - ), - ])) as any; + 30000, + "session.create timeout" + )) as any; log("AI cleanup: session.create result", { rawType: typeof created, keys: Object.keys(created || {}), @@ -289,8 +326,8 @@ async function callViaOpencodeWithClient( log("AI cleanup: session created", { sessionID, createMs: Date.now() - t0 }); try { - const TIMEOUT_MS = 120000; - const promptResult = await Promise.race([ + const TIMEOUT_MS = OPENCODE_CLEANUP_TIMEOUT_MS; + const promptResult = await raceWithTimeout( v2Client.session.prompt({ sessionID, model: { @@ -299,29 +336,21 @@ async function callViaOpencodeWithClient( }, system: systemPrompt, parts: [{ type: "text", text: prompt }], - noReply: true, + // `noReply` suppresses assistant generation; cleanup needs the JSON reply (#177). + noReply: false, }), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`opencodeClient prompt timeout after ${TIMEOUT_MS}ms`)), - TIMEOUT_MS - ) - ), - ]); + TIMEOUT_MS, + `opencodeClient prompt timeout after ${TIMEOUT_MS}ms` + ); log("AI cleanup: session.prompt done", { promptMs: Date.now() - t0 }); - const info = ( - promptResult as { - data?: { info?: { text?: string; error?: { name: string; data?: { message?: string } } } }; - } - ).data?.info; + const { info, rawText } = extractTextFromPromptResult(promptResult); if (!info) throw new Error("prompt response missing info"); if (info.error) throw new Error(`opencode reported ${info.error.name}: ${info.error.data?.message ?? ""}`); - const rawText = info.text?.trim() || ""; const jsonMatch = rawText.match(/\{[\s\S]*\}/); if (!jsonMatch) throw new Error("AI response did not contain valid JSON"); diff --git a/src/web/app.js b/src/web/app.js index 7a5afe4..8fca48b 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -34,7 +34,7 @@ async function fetchAPI(endpoint, options = {}) { const controller = new AbortController(); const timeoutMs = options.timeout || - (options.method === "POST" && endpoint.includes("/ai-cleanup") ? 180000 : 60000); + (options.method === "POST" && endpoint.includes("/ai-cleanup") ? 300000 : 60000); const { timeout: _, headers: extraHeaders, ...fetchOptions } = options; const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(API_BASE + endpoint, { @@ -1332,7 +1332,7 @@ async function showAICleanup() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ includeIds: ids }), - timeout: 180000, + timeout: 300000, }); loading.classList.add("hidden"); if (!result.success) { diff --git a/tests/ai-cleanup.test.ts b/tests/ai-cleanup.test.ts new file mode 100644 index 0000000..5a990ba --- /dev/null +++ b/tests/ai-cleanup.test.ts @@ -0,0 +1,247 @@ +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"; +import { extractTextFromPromptResult } from "../src/services/user-profile/ai-cleanup.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("extractTextFromPromptResult", () => { + it("reads text from data.parts (SDK wrapped shape)", () => { + const { info, rawText } = extractTextFromPromptResult({ + data: { + info: {}, + parts: [ + { type: "text", text: '{"mapping":{"kept":["pref_0"],"merged":[],"removed":[]}}' }, + { type: "step-finish" }, + ], + }, + }); + + expect(info).toEqual({}); + expect(rawText).toContain('"kept":["pref_0"]'); + }); + + it("reads text from top-level parts when data wrapper is absent", () => { + const { info, rawText } = extractTextFromPromptResult({ + info: {}, + parts: [{ type: "text", text: '{"ok":true}' }], + }); + + expect(info).toEqual({}); + expect(rawText).toBe('{"ok":true}'); + }); + + it("does not use nonexistent info.text", () => { + const { rawText } = extractTextFromPromptResult({ + data: { + info: { text: '{"fromInfo":true}' }, + parts: [], + }, + }); + + expect(rawText).toBe(""); + }); + + it("surfaces assistant errors from info", () => { + const { info, rawText } = extractTextFromPromptResult({ + data: { + info: { error: { name: "ApiError", data: { message: "boom" } } }, + parts: [], + }, + }); + + expect(info?.error?.name).toBe("ApiError"); + expect(rawText).toBe(""); + }); +}); + +const aiCleanupUrl = new URL("../src/services/user-profile/ai-cleanup.js", import.meta.url).href; +const configUrl = new URL("../src/config.js", import.meta.url).href; +const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; +const opencodeProviderLoaderUrl = new URL( + "../src/services/ai/opencode-provider-loader.js", + import.meta.url +).href; + +function runCleanupScenario(opts: { + promptThrows?: boolean; + clientAvailable?: boolean; + withExternalApi?: boolean; +}) { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-ai-cleanup-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + const promptThrows = opts.promptThrows ?? false; + const clientAvailable = opts.clientAvailable ?? true; + const withExternalApi = opts.withExternalApi ?? false; + + const script = ` +import { mock } from "bun:test"; + +const promptCalls = []; +const deleteCalls = []; +let externalFetchCalled = false; + +const cleanupJson = JSON.stringify({ + preferences: [{ id: "pref_0", category: "style", description: "Prefer concise answers" }], + patterns: [], + workflows: [], + mapping: { + kept: ["pref_0"], + merged: [], + removed: ["pref_1"], + }, +}); + +mock.module(${JSON.stringify(configUrl)}, () => ({ + CONFIG: { + opencodeProvider: "openai", + opencodeModel: "gpt-test", + memoryModel: ${withExternalApi ? '"gpt-ext"' : "undefined"}, + memoryApiUrl: ${withExternalApi ? '"http://example.test/v1"' : "undefined"}, + memoryApiKey: "test-key", + }, +})); + +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); + +mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ + loadOpencodeProvider: async () => ({ + getV2Client: () => + ${clientAvailable} + ? { + session: { + create: async () => ({ data: { id: "sess-cleanup-1" } }), + prompt: async (args) => { + promptCalls.push(args); + if (${promptThrows}) { + throw new Error("prompt failed for test"); + } + return { + data: { + info: {}, + parts: [{ type: "text", text: cleanupJson }], + }, + }; + }, + delete: async (args) => { + deleteCalls.push(args); + }, + }, + } + : null, + }), +})); + +if (${withExternalApi}) { + globalThis.fetch = async () => { + externalFetchCalled = true; + return { + ok: true, + status: 200, + json: async () => ({ + choices: [{ message: { content: cleanupJson } }], + }), + }; + }; +} + +const { aiCleanupProfile } = await import(${JSON.stringify(aiCleanupUrl)}); + +const profile = { + preferences: [ + { category: "style", description: "Prefer concise answers", confidence: 0.9, frequency: 2 }, + { category: "style", description: "likes short replies", confidence: 0.5, frequency: 1 }, + ], + patterns: [], + workflows: [], +}; + +let errorMessage = null; +let result = null; +try { + result = await aiCleanupProfile(profile); +} catch (e) { + errorMessage = String(e?.message || e); +} + +console.log( + JSON.stringify({ + errorMessage, + promptCalls, + deleteCalls, + externalFetchCalled, + kept: result?.diff?.kept ?? null, + removed: result?.diff?.removed?.map((r) => r.id) ?? null, + noReply: promptCalls[0]?.noReply, + }) +); +`; + + writeFileSync(scriptPath, script); + + const spawned = Bun.spawnSync({ + cmd: [process.execPath, scriptPath], + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = Buffer.from(spawned.stdout).toString("utf8").trim(); + const stderr = Buffer.from(spawned.stderr).toString("utf8").trim(); + + return { + exitCode: spawned.exitCode, + stdout, + stderr, + parsed: stdout ? JSON.parse(stdout) : null, + }; +} + +describe("AI cleanup opencode provider path (#177)", () => { + it("succeeds with noReply false and text parts", () => { + const result = runCleanupScenario({}); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.errorMessage).toBeNull(); + expect(result.parsed?.noReply).toBe(false); + expect(result.parsed?.promptCalls).toHaveLength(1); + expect(result.parsed?.deleteCalls).toEqual([{ sessionID: "sess-cleanup-1" }]); + expect(result.parsed?.kept).toEqual(["Prefer concise answers"]); + expect(result.parsed?.removed).toEqual(["pref_1"]); + expect(result.parsed?.externalFetchCalled).toBe(false); + }); + + it("surfaces opencode errors instead of masking as missing provider", () => { + const result = runCleanupScenario({ promptThrows: true, withExternalApi: false }); + + expect(result.exitCode).toBe(0); + expect(result.parsed?.errorMessage).toContain("prompt failed for test"); + expect(result.parsed?.errorMessage).not.toContain("No AI provider configured"); + expect(result.parsed?.externalFetchCalled).toBe(false); + }); + + it("does not fall back to external API when opencode client is available and fails", () => { + const result = runCleanupScenario({ promptThrows: true, withExternalApi: true }); + + expect(result.exitCode).toBe(0); + expect(result.parsed?.errorMessage).toContain("prompt failed for test"); + expect(result.parsed?.externalFetchCalled).toBe(false); + }); + + it("falls back to external API when opencode client is unavailable", () => { + const result = runCleanupScenario({ clientAvailable: false, withExternalApi: true }); + + expect(result.exitCode).toBe(0); + expect(result.parsed?.errorMessage).toBeNull(); + expect(result.parsed?.externalFetchCalled).toBe(true); + expect(result.parsed?.kept).toEqual(["Prefer concise answers"]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f835efe..888d707 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "module": "ESNext", "moduleDetection": "force", "allowJs": true, + "types": ["bun-types"], "moduleResolution": "bundler", "allowImportingTsExtensions": false, @@ -30,4 +31,3 @@ "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } -