diff --git a/README.md b/README.md index a34cfc97..3f6dbec4 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ Slow reasoning models (or very large profile prompts) can exceed the default 90 Supported providers: any provider listed by `opencode providers list` (e.g. `anthropic`, `openai`, `github-copilot`, ...). -If `opencodeProvider` and `opencodeModel` are set, they take precedence over the manual `memoryProvider` settings below. +If `opencodeProvider` and `opencodeModel` are set, they take precedence over the manual `memoryProvider` settings below by default (`"autoCaptureProviderOrder": "opencode-first"`). Set `"autoCaptureProviderOrder": "external-first"` to prefer the manual API when both are configured (useful when the host session API is busy/slow); the other channel is used as fallback. Lower `"opencodeTimeoutMs"` to fail over from the opencode channel sooner. **Follow the session model:** set `"opencodeModel": "inherit"` to use a concrete OpenCode model at call time instead of a pinned id. For **auto-capture**, each prompt is recorded via the `chat.params` hook and the capture request reuses that prompt's provider/model. For **profile learning** and other structured-output paths (which are not tied to a single user message), `inherit` falls back to the most recent model in OpenCode's `model.json` recent list (preferring the configured `opencodeProvider`). Sending the literal model id `inherit` is never valid and previously caused `ProviderModelNotFoundError: Model not found: /inherit` on those paths. `opencodeProvider` is still required as the normal config gate. diff --git a/opencode-mem.example.jsonc b/opencode-mem.example.jsonc index b7680624..f63b3ca1 100644 --- a/opencode-mem.example.jsonc +++ b/opencode-mem.example.jsonc @@ -182,6 +182,13 @@ "autoCaptureEnabled": true, + // When both opencodeProvider and manual memoryModel/memoryApiUrl are set, which + // channel to try first for auto-capture summaries. Default "opencode-first". + // Prefer "external-first" if the host session API is slow/busy and you want the + // faster external API path. Failed primary falls back to the other channel. + // Faster opencode failover: also lower "opencodeTimeoutMs" (default 90000). + // "autoCaptureProviderOrder": "external-first", + // Provider type: "atlas-cloud" | "cheaper-inference" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" // Note: "openai-chat" is a generic OpenAI API-compatible mode. // Any service that follows the OpenAI Chat Completions API can use it via custom "memoryApiUrl". diff --git a/src/config.ts b/src/config.ts index 6b138987..51e90cea 100644 --- a/src/config.ts +++ b/src/config.ts @@ -65,6 +65,13 @@ interface OpenCodeMemConfig { autoCaptureMaxRetries?: number; autoCaptureMaxContextBytes?: number; autoCaptureLanguage?: string; + /** + * Which auto-capture summary channel to try first when both opencode and + * manual (memoryModel/memoryApiUrl) providers are configured. + * Default "opencode-first". Use "external-first" for lower latency when the + * host session API is busy; failed primary channel falls back to the other. + */ + autoCaptureProviderOrder?: "opencode-first" | "external-first"; memoryProvider?: | "atlas-cloud" | "cheaper-inference" @@ -256,6 +263,7 @@ const DEFAULTS: Required< autoCaptureIterationTimeout: 30000, autoCaptureMaxRetries: 3, autoCaptureMaxContextBytes: 131072, + autoCaptureProviderOrder: "opencode-first", aiSessionRetentionDays: 7, webServerEnabled: true, webServerPort: 4747, @@ -600,6 +608,13 @@ const CONFIG_TEMPLATE = `{ // Prevents HTTP 400 context overflows on models with ~131K token windows (e.g. Groq Llama). // Rough guide: tokens ≈ bytes / 4 for mixed code/prose. "autoCaptureMaxContextBytes": 131072, + + // When both opencodeProvider and manual memoryModel/memoryApiUrl are set, which + // channel to try first for auto-capture summaries. Default "opencode-first". + // Prefer "external-first" if the host session API is slow/busy (~15–50s) and + // you want the faster external API path (~3s). Failed primary falls back. + // Faster opencode failover also: lower "opencodeTimeoutMs" (default 90000). + // "autoCaptureProviderOrder": "external-first", // Days to keep AI session history before cleanup "aiSessionRetentionDays": 7, @@ -899,6 +914,13 @@ export function normalizeAutoCaptureMaxContextBytes(value: number): number { return value; } +export type AutoCaptureProviderOrder = "opencode-first" | "external-first"; + +/** Invalid / unset values fall back to the historical default (opencode-first). */ +export function normalizeAutoCaptureProviderOrder(value: unknown): AutoCaptureProviderOrder { + return value === "external-first" ? "external-first" : "opencode-first"; +} + export function normalizeAutoCleanupRetentionDays(value: number): number { if (!Number.isSafeInteger(value) || value <= 0) { throw new Error(`Invalid autoCleanupRetentionDays config: ${value}`); @@ -1059,6 +1081,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { autoCaptureMaxRetries: fileConfig.autoCaptureMaxRetries ?? DEFAULTS.autoCaptureMaxRetries, autoCaptureMaxContextBytes, autoCaptureLanguage: fileConfig.autoCaptureLanguage, + autoCaptureProviderOrder: normalizeAutoCaptureProviderOrder( + fileConfig.autoCaptureProviderOrder + ), memoryProvider, memoryModel: fileConfig.memoryModel, memoryApiUrl: fileConfig.memoryApiUrl, diff --git a/src/services/api-handlers.ts b/src/services/api-handlers.ts index 729abd5b..f3658fa3 100644 --- a/src/services/api-handlers.ts +++ b/src/services/api-handlers.ts @@ -896,7 +896,12 @@ export async function handleUnpinMemory(id: string): Promise> } export async function handleRunCleanup(): Promise< - ApiResponse<{ deletedCount: number; userCount: number; projectCount: number }> + ApiResponse<{ + deletedCount: number; + userCount: number; + projectCount: number; + aiSessionsDeleted?: number; + }> > { try { const { cleanupService } = await import("./cleanup-service.js"); diff --git a/src/services/auto-capture.ts b/src/services/auto-capture.ts index cf8283d9..db756431 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"; import { randomUUID } from "node:crypto"; import { memoryClient } from "./client.js"; import { getTags } from "./tags.js"; -import { log } from "./logger.js"; +import { log, serializeError } from "./logger.js"; import { CONFIG } from "../config.js"; import { userPromptManager, type UserPrompt } from "./user-prompt/user-prompt-manager.js"; import { loadOpencodeProvider } from "./ai/opencode-provider-loader.js"; @@ -94,6 +94,7 @@ async function capturePrompt( ): Promise { let claimedPromptId: string | null = null; let attempt = prompt.capture_attempts || 0; + let phase: "claim" | "context" | "summary" | "persist" = "claim"; try { if (!(await userPromptManager.claimPrompt(prompt.id))) { @@ -108,6 +109,7 @@ async function capturePrompt( throw new Error("Client not available"); } + phase = "context"; const response = await ctx.client.session.messages({ path: { id: sessionID }, }); @@ -140,6 +142,7 @@ async function capturePrompt( getAutoCaptureMarkdownBudget() ); + phase = "summary"; let summaryResult: { summary: string; type: string; tags: string[] } | null; try { summaryResult = await generateSummary(ctx, context, sessionID, prompt.content, prompt); @@ -164,6 +167,7 @@ async function capturePrompt( ? `${summaryResult.summary}\n\nTags: ${summaryResult.tags.join(", ")}` : summaryResult.summary; + phase = "persist"; const result = await memoryClient.addMemory(summaryWithTags, tags.project.tag, { source: "auto-capture" as any, type: summaryResult.type as any, @@ -206,12 +210,13 @@ async function capturePrompt( throw new Error(`Memory persistence failed: ${result.error || "database write failed"}`); } } catch (error) { - const errMsg = error instanceof Error ? error.message : String(error); - await userPromptManager.recordFailedAttempt(prompt.id); if (attempt < maxRetries) { - log(`Auto-capture warning (attempt ${attempt}/${maxRetries})`, { error: errMsg }); + log(`Auto-capture warning (attempt ${attempt}/${maxRetries})`, { + phase, + ...serializeError(error), + }); await new Promise((resolve) => setTimeout(resolve, RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1)) ); @@ -222,7 +227,10 @@ async function capturePrompt( } } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - log(`Auto-capture final error after ${attempt} attempts`, { error: errMsg }); + log(`Auto-capture final error after ${attempt} attempts`, { + phase, + ...serializeError(error), + }); if (CONFIG.showErrorToasts) { const shortReason = errMsg.length > 100 ? errMsg.substring(0, 100) + "..." : errMsg; await ctx.client?.tui @@ -513,63 +521,23 @@ export function buildMarkdownContext( return truncateToMaxBytes(result, maxContextBytes, CONTEXT_TRUNCATION_MARKER); } -async function generateSummary( - ctx: PluginInput, - context: string, - sessionID: string, - userPrompt: string, - prompt?: { id?: string; providerId: string | null; modelId: string | null } -): Promise<{ summary: string; type: string; tags: string[] } | null> { - let opencodeProviderError: unknown; - - // Opencode provider path (when opencodeProvider + opencodeModel configured) - if (CONFIG.opencodeProvider && CONFIG.opencodeModel) { - try { - if (CONFIG.memoryModel) { - log("opencodeProvider takes precedence over memoryModel for auto-capture"); - } - - const { ensureProviderConnected, getV2Client, generateStructuredOutput } = - await loadOpencodeProvider(); - - // "inherit" resolves to the model opencode used for the captured prompt - // (recorded by the chat.params hook). Without a concrete model id, the - // provider path cannot issue a structured-output request. - let providerID = CONFIG.opencodeProvider; - let modelID = CONFIG.opencodeModel; - if (modelID === "inherit") { - if (!prompt?.providerId || !prompt?.modelId) { - throw new Error( - "opencode-mem: opencodeModel is 'inherit' but no session model was recorded for this prompt" - ); - } - providerID = prompt.providerId; - modelID = prompt.modelId; - } +type CaptureSummary = { summary: string; type: string; tags: string[] }; +type CaptureChannel = "opencode" | "external"; - // One refresh-on-miss before failing: user-config providers register - // seconds after plugin setup, so the init snapshot may lag the host. - if (!(await ensureProviderConnected(providerID))) { - throw new Error( - `opencode provider '${providerID}' is not connected. Check your opencode provider configuration.` - ); - } +function isOpencodeCaptureConfigured(): boolean { + return Boolean(CONFIG.opencodeProvider && CONFIG.opencodeModel); +} - const v2Client = getV2Client(); - if (!v2Client) { - throw new Error( - "opencode-mem: v2 client not initialized; cannot perform structured-output capture" - ); - } +function isExternalCaptureConfigured(): boolean { + return Boolean(CONFIG.memoryModel && CONFIG.memoryApiUrl); +} - const { detectLanguage, getLanguageName } = await import("./language-detector.js"); - const targetLang = - CONFIG.autoCaptureLanguage === "auto" || !CONFIG.autoCaptureLanguage - ? detectLanguage(userPrompt) - : CONFIG.autoCaptureLanguage; - const langName = getLanguageName(targetLang); +function isChannelConfigured(channel: CaptureChannel): boolean { + return channel === "opencode" ? isOpencodeCaptureConfigured() : isExternalCaptureConfigured(); +} - const systemPrompt = `You are a technical memory recorder for a software development project. +function buildCaptureSystemPrompt(langName: string): string { + return `You are a technical memory recorder for a software development project. RULES: 1. ONLY capture technical work (code, bugs, features, architecture, config) @@ -588,100 +556,113 @@ FORMAT: SKIP if: greetings, casual chat, no code/decisions made CAPTURE if: code changed, bug fixed, feature added, decision made`; +} - const { z } = await import("zod"); - const schema = z.object({ - summary: z.string(), - type: z.string(), - tags: z.array(z.string()), - }); - const aiPrompt = buildBoundedSummaryPrompt(context, systemPrompt, z.toJSONSchema(schema)); - - const result = await generateStructuredOutput({ - client: v2Client, - providerID, - modelID, - ...(CONFIG.opencodeVariant ? { variant: CONFIG.opencodeVariant } : {}), - systemPrompt, - userPrompt: aiPrompt, - schema, - }); +async function resolveCaptureLanguage(userPrompt: string): Promise { + const { detectLanguage, getLanguageName } = await import("./language-detector.js"); + const targetLang = + CONFIG.autoCaptureLanguage === "auto" || !CONFIG.autoCaptureLanguage + ? detectLanguage(userPrompt) + : CONFIG.autoCaptureLanguage; + return getLanguageName(targetLang); +} - return { - summary: result.summary, - type: result.type, - tags: (result.tags || []).map((t: string) => t.toLowerCase().trim()), - }; - } catch (e) { - opencodeProviderError = e; - log("auto-capture: opencode provider failed, falling back to external API", { - error: String(e), - }); +function showFallbackToast(ctx: PluginInput, failedChannel: CaptureChannel, error: unknown): void { + if (!CONFIG.showErrorToasts) return; + const errMsg = error instanceof Error ? error.message : String(error); + const shortReason = errMsg.length > 100 ? errMsg.substring(0, 100) + "..." : errMsg; + const label = failedChannel === "opencode" ? "OpenCode provider" : "External API"; + ctx.client?.tui + .showToast({ + body: { + title: "Using fallback provider", + message: `${label} failed (${shortReason}); using configured fallback.`, + variant: "warning", + duration: 5000, + }, + }) + .catch(() => {}); +} + +async function tryOpencodeSummary( + context: string, + userPrompt: string, + prompt?: { id?: string; providerId: string | null; modelId: string | null } +): Promise { + const { ensureProviderConnected, getV2Client, generateStructuredOutput } = + await loadOpencodeProvider(); + + // "inherit" resolves to the model opencode used for the captured prompt + // (recorded by the chat.params hook). Without a concrete model id, the + // provider path cannot issue a structured-output request. + let providerID = CONFIG.opencodeProvider!; + let modelID = CONFIG.opencodeModel!; + if (modelID === "inherit") { + if (!prompt?.providerId || !prompt?.modelId) { + throw new Error( + "opencode-mem: opencodeModel is 'inherit' but no session model was recorded for this prompt" + ); } + providerID = prompt.providerId; + modelID = prompt.modelId; } - // Existing manual config path - if (!CONFIG.memoryModel || !CONFIG.memoryApiUrl) { - if (opencodeProviderError) { - throw opencodeProviderError; - } - throw new Error("External API not configured for auto-capture"); + // One refresh-on-miss before failing: user-config providers register + // seconds after plugin setup, so the init snapshot may lag the host. + if (!(await ensureProviderConnected(providerID))) { + throw new Error( + `opencode provider '${providerID}' is not connected. Check your opencode provider configuration.` + ); } - // Opencode provider failed but a manual fallback is configured — surface a - // non-blocking warning so the user knows their primary provider is broken. - if (opencodeProviderError && CONFIG.showErrorToasts) { - const errMsg = - opencodeProviderError instanceof Error - ? opencodeProviderError.message - : String(opencodeProviderError); - const shortReason = errMsg.length > 100 ? errMsg.substring(0, 100) + "..." : errMsg; - ctx.client?.tui - .showToast({ - body: { - title: "Using fallback provider", - message: `OpenCode provider failed (${shortReason}); using configured fallback.`, - variant: "warning", - duration: 5000, - }, - }) - .catch(() => {}); + const v2Client = getV2Client(); + if (!v2Client) { + throw new Error( + "opencode-mem: v2 client not initialized; cannot perform structured-output capture" + ); } + const langName = await resolveCaptureLanguage(userPrompt); + const systemPrompt = buildCaptureSystemPrompt(langName); + + const { z } = await import("zod"); + const schema = z.object({ + summary: z.string(), + type: z.string(), + tags: z.array(z.string()), + }); + const aiPrompt = buildBoundedSummaryPrompt(context, systemPrompt, z.toJSONSchema(schema)); + + const result = await generateStructuredOutput({ + client: v2Client, + providerID, + modelID, + ...(CONFIG.opencodeVariant ? { variant: CONFIG.opencodeVariant } : {}), + systemPrompt, + userPrompt: aiPrompt, + schema, + }); + + return { + summary: result.summary, + type: result.type, + tags: (result.tags || []).map((t: string) => t.toLowerCase().trim()), + }; +} + +async function tryExternalSummary( + context: string, + sessionID: string, + userPrompt: string, + prompt?: { id?: string; providerId: string | null; modelId: string | null } +): Promise { const { AIProviderFactory } = await import("./ai/ai-provider-factory.js"); const { buildMemoryProviderConfig } = await import("./ai/provider-config.js"); - const { detectLanguage, getLanguageName } = await import("./language-detector.js"); const providerConfig = buildMemoryProviderConfig(CONFIG); - const provider = AIProviderFactory.createProvider(CONFIG.memoryProvider, providerConfig); - - const targetLang = - CONFIG.autoCaptureLanguage === "auto" || !CONFIG.autoCaptureLanguage - ? detectLanguage(userPrompt) - : CONFIG.autoCaptureLanguage; - - const langName = getLanguageName(targetLang); - - const systemPrompt = `You are a technical memory recorder for a software development project. - -RULES: -1. ONLY capture technical work (code, bugs, features, architecture, config) -2. SKIP non-technical by returning type="skip" -3. NO meta-commentary or behavior analysis -4. Include specific file names, functions, technical details -5. Generate 2-4 technical tags (e.g., "react", "auth", "bug-fix") -6. You MUST write the summary in ${langName}. - -FORMAT: -## Request -[1-2 sentences: what was requested, in ${langName}] - -## Outcome -[1-2 sentences: what was done, include files/functions, in ${langName}] - -SKIP if: greetings, casual chat, no code/decisions made -CAPTURE if: code changed, bug fixed, feature added, decision made`; + const langName = await resolveCaptureLanguage(userPrompt); + const systemPrompt = buildCaptureSystemPrompt(langName); const toolSchema = { type: "function" as const, @@ -730,3 +711,55 @@ CAPTURE if: code changed, bug fixed, feature added, decision made`; tags: (result.data.tags || []).map((t: string) => t.toLowerCase().trim()), }; } + +async function generateSummary( + ctx: PluginInput, + context: string, + sessionID: string, + userPrompt: string, + prompt?: { id?: string; providerId: string | null; modelId: string | null } +): Promise { + const order = CONFIG.autoCaptureProviderOrder ?? "opencode-first"; + const channels: CaptureChannel[] = + order === "external-first" ? ["external", "opencode"] : ["opencode", "external"]; + + if ( + order === "opencode-first" && + isOpencodeCaptureConfigured() && + isExternalCaptureConfigured() + ) { + log("opencodeProvider takes precedence over memoryModel for auto-capture"); + } else if ( + order === "external-first" && + isOpencodeCaptureConfigured() && + isExternalCaptureConfigured() + ) { + log("external API takes precedence over opencodeProvider for auto-capture"); + } + + let lastError: unknown; + + for (let i = 0; i < channels.length; i++) { + const channel = channels[i]!; + if (!isChannelConfigured(channel)) continue; + + try { + return channel === "opencode" + ? await tryOpencodeSummary(context, userPrompt, prompt) + : await tryExternalSummary(context, sessionID, userPrompt, prompt); + } catch (error) { + lastError = error; + const nextConfigured = channels.slice(i + 1).find((c) => isChannelConfigured(c)); + if (!nextConfigured) break; + + const fallbackLabel = nextConfigured === "external" ? "external API" : "opencode provider"; + log(`auto-capture: ${channel} provider failed, falling back to ${fallbackLabel}`, { + ...serializeError(error), + }); + showFallbackToast(ctx, channel, error); + } + } + + if (lastError) throw lastError; + throw new Error("No auto-capture provider configured"); +} diff --git a/src/services/cleanup-service.ts b/src/services/cleanup-service.ts index 69cceb42..234312d4 100644 --- a/src/services/cleanup-service.ts +++ b/src/services/cleanup-service.ts @@ -13,6 +13,7 @@ interface CleanupResult { promptsDeleted: number; linkedMemoriesDeleted: number; pinnedMemoriesSkipped: number; + aiSessionsDeleted: number; } export class CleanupService { @@ -111,6 +112,15 @@ export class CleanupService { log("Cleanup: VACUUM skipped (DB busy)", { error: String(err) }); } + let aiSessionsDeleted = 0; + try { + const { AIProviderFactory } = await import("./ai/ai-provider-factory.js"); + aiSessionsDeleted = await AIProviderFactory.cleanupExpiredSessions(); + log("Cleanup: expired AI sessions deleted", { aiSessionsDeleted }); + } catch (err) { + log("Cleanup: AI session cleanup failed", { error: String(err) }); + } + return { deletedCount: totalDeleted, userCount: userDeleted, @@ -118,6 +128,7 @@ export class CleanupService { promptsDeleted, linkedMemoriesDeleted, pinnedMemoriesSkipped: pinnedSkipped, + aiSessionsDeleted, }; } finally { this.isRunning = false; diff --git a/src/services/client.ts b/src/services/client.ts index 66a2d958..2d8e1d19 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -10,7 +10,7 @@ import { type MemoryScopeRef, } from "./memory-scope.js"; import { CONFIG } from "../config.js"; -import { log } from "./logger.js"; +import { log, serializeError } from "./logger.js"; import type { MemoryType } from "../types/index.js"; import type { MemoryRecord } from "./turso/types.js"; @@ -169,14 +169,17 @@ export class LocalMemoryClient { [key: string]: unknown; } ) { + let phase: "embed" | "embed_tags" | "write" = "embed"; try { await this.initialize(); const tags = metadata?.tags || []; + phase = "embed"; const vector = await embeddingService.embedWithTimeout(content, { task: "document" }); let tagsVector: Float32Array | undefined = undefined; if (tags.length > 0) { + phase = "embed_tags"; tagsVector = await embeddingService.embedWithTimeout(formatTagsForEmbedding(tags), { task: "document", }); @@ -184,7 +187,8 @@ export class LocalMemoryClient { const { scope, hash } = extractScopeFromContainerTag(containerTag); - return tursoShardManager.withScopeWriteLock(scope, hash, async () => { + phase = "write"; + return await tursoShardManager.withScopeWriteLock(scope, hash, async () => { const shard = await tursoShardManager.getWriteShard(scope, hash); const id = `mem_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; @@ -236,9 +240,9 @@ export class LocalMemoryClient { }; }); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - log("addMemory: error", { error: errorMessage }); - return { success: false as const, error: errorMessage }; + const serialized = serializeError(error); + log("addMemory: error", { phase, ...serialized }); + return { success: false as const, error: serialized.error }; } } diff --git a/src/services/logger.ts b/src/services/logger.ts index b1ab653f..d7259d6d 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -117,6 +117,30 @@ function ensureLoggerInitialized() { (globalThis as any)[GLOBAL_LOGGER_KEY] = logDir; } +/** Structured fields for log payloads so callers keep `error` + stack/cause. */ +export function serializeError(error: unknown): { + error: string; + name?: string; + stack?: string; + cause?: string; +} { + if (error instanceof Error) { + const cause = + error.cause instanceof Error + ? error.cause.message + : error.cause !== undefined + ? String(error.cause) + : undefined; + return { + error: error.message, + name: error.name, + ...(error.stack ? { stack: error.stack } : {}), + ...(cause !== undefined ? { cause } : {}), + }; + } + return { error: String(error) }; +} + export function log(message: string, data?: unknown) { ensureLoggerInitialized(); diff --git a/tests/add-memory-phase-logging.test.ts b/tests/add-memory-phase-logging.test.ts new file mode 100644 index 00000000..2d0f66d5 --- /dev/null +++ b/tests/add-memory-phase-logging.test.ts @@ -0,0 +1,115 @@ +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[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const clientUrl = new URL("../src/services/client.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 embeddingUrl = new URL("../src/services/embedding.js", import.meta.url).href; +const readyUrl = new URL("../src/services/turso/ready.js", import.meta.url).href; +const shardManagerUrl = new URL("../src/services/turso/shard-manager.js", import.meta.url).href; +const vectorUtilsUrl = new URL("../src/services/turso/vector-utils.js", import.meta.url).href; +const connectionManagerUrl = new URL("../src/services/turso/connection-manager.js", import.meta.url) + .href; +const vectorSearchUrl = new URL("../src/services/turso/vector-search.js", import.meta.url).href; + +function runAddMemoryPhaseScenario() { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-add-memory-phase-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + + const script = ` +import { mock } from "bun:test"; + +const logs = []; + +mock.module(${JSON.stringify(configUrl)}, () => ({ + CONFIG: { storagePath: ${JSON.stringify(dir)} }, +})); + +mock.module(${JSON.stringify(loggerUrl)}, () => ({ + log: (message, data) => logs.push({ message, data }), + serializeError: (error) => ({ + error: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : undefined, + stack: error instanceof Error ? error.stack : undefined, + }), +})); + +mock.module(${JSON.stringify(readyUrl)}, () => ({ + ensureTursoReady: async () => {}, +})); + +mock.module(${JSON.stringify(embeddingUrl)}, () => ({ + embeddingService: { + embedWithTimeout: async () => { + throw new Error("Timeout after 30000ms"); + }, + }, +})); + +mock.module(${JSON.stringify(vectorUtilsUrl)}, () => ({ + formatTagsForEmbedding: (tags) => tags.join(" "), +})); + +mock.module(${JSON.stringify(shardManagerUrl)}, () => ({ + tursoShardManager: { + withScopeWriteLock: async (_s, _h, fn) => fn(), + getWriteShard: async () => ({ id: "shard-1", dbPath: "/tmp/x.db" }), + incrementVectorCount: async () => {}, + }, +})); + +mock.module(${JSON.stringify(connectionManagerUrl)}, () => ({ + tursoConnectionManager: { getConnection: async () => ({}) }, +})); + +mock.module(${JSON.stringify(vectorSearchUrl)}, () => ({ + tursoVectorSearch: { insertVector: async () => {} }, +})); + +const { memoryClient } = await import(${JSON.stringify(clientUrl)}); +const result = await memoryClient.addMemory("hello", "opencode_project_test"); +console.log(JSON.stringify({ result, logs })); +`; + + writeFileSync(scriptPath, script); + + const result = Bun.spawnSync({ + cmd: [process.execPath, scriptPath], + stdout: "pipe", + stderr: "pipe", + }); + + const stdout = Buffer.from(result.stdout).toString("utf8").trim(); + return { + exitCode: result.exitCode, + stderr: Buffer.from(result.stderr).toString("utf8").trim(), + parsed: stdout ? JSON.parse(stdout) : null, + }; +} + +describe("addMemory phase logging", () => { + it("logs embed phase with structured error fields", () => { + const result = runAddMemoryPhaseScenario(); + expect(result.exitCode).toBe(0); + expect(result.parsed.result.success).toBe(false); + const entry = result.parsed.logs.find( + (l: { message: string }) => l.message === "addMemory: error" + ); + expect(entry).toBeTruthy(); + expect(entry.data.phase).toBe("embed"); + expect(entry.data.error).toBe("Timeout after 30000ms"); + expect(entry.data.name).toBe("Error"); + expect(entry.data.stack).toContain("Timeout after 30000ms"); + }); +}); diff --git a/tests/ai-cleanup.test.ts b/tests/ai-cleanup.test.ts index fbaa5a62..0750e3e9 100644 --- a/tests/ai-cleanup.test.ts +++ b/tests/ai-cleanup.test.ts @@ -120,7 +120,7 @@ mock.module(${JSON.stringify(configUrl)}, () => ({ }, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ loadOpencodeProvider: async () => ({ diff --git a/tests/api-handlers-container-tag-traversal.test.ts b/tests/api-handlers-container-tag-traversal.test.ts index 1925d152..79a7ebf7 100644 --- a/tests/api-handlers-container-tag-traversal.test.ts +++ b/tests/api-handlers-container-tag-traversal.test.ts @@ -74,6 +74,7 @@ mock.module(${JSON.stringify(userPromptManagerUrl)}, () => ({ mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), })); const { handleAddMemory } = await import(${JSON.stringify(apiHandlersUrl)}); diff --git a/tests/api-handlers-profile-strip.test.ts b/tests/api-handlers-profile-strip.test.ts index cc3c3746..a895105e 100644 --- a/tests/api-handlers-profile-strip.test.ts +++ b/tests/api-handlers-profile-strip.test.ts @@ -58,6 +58,7 @@ const profileWithEmbeddings = ${JSON.stringify(profileWithEmbeddings)}; mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), })); mock.module(${JSON.stringify(userPromptManagerUrl)}, () => ({ diff --git a/tests/auto-capture-concurrency.test.ts b/tests/auto-capture-concurrency.test.ts index 8de5e631..24dc152a 100644 --- a/tests/auto-capture-concurrency.test.ts +++ b/tests/auto-capture-concurrency.test.ts @@ -89,7 +89,7 @@ mock.module(${JSON.stringify(u("src/services/context.js"))}, () => ({ formatCont mock.module(${JSON.stringify(u("src/services/privacy.js"))}, () => ({ stripPrivateContent: (v) => v, isFullyPrivate: () => false, })); -mock.module(${JSON.stringify(u("src/services/logger.js"))}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(u("src/services/logger.js"))}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(u("src/services/language-detector.js"))}, () => ({ detectLanguage: () => "en", getLanguageName: () => "English", })); diff --git a/tests/auto-capture-provider-order.test.ts b/tests/auto-capture-provider-order.test.ts new file mode 100644 index 00000000..9dc83f4f --- /dev/null +++ b/tests/auto-capture-provider-order.test.ts @@ -0,0 +1,233 @@ +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[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const autoCaptureUrl = new URL("../src/services/auto-capture.js", import.meta.url).href; +const clientUrl = new URL("../src/services/client.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 loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; +const languageUrl = new URL("../src/services/language-detector.js", import.meta.url).href; +const opencodeProviderLoaderUrl = new URL( + "../src/services/ai/opencode-provider-loader.js", + import.meta.url +).href; +const providerConfigUrl = new URL("../src/services/ai/provider-config.js", import.meta.url).href; +const aiProviderFactoryUrl = new URL("../src/services/ai/ai-provider-factory.js", import.meta.url) + .href; + +function runOrderScenario(opts: { + order: "opencode-first" | "external-first"; + failOpencode?: boolean; + failExternal?: boolean; +}) { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-provider-order-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + + const script = ` +import { mock } from "bun:test"; + +const channelCalls = []; +const logs = []; + +mock.module(${JSON.stringify(configUrl)}, () => ({ + CONFIG: { + autoCaptureMaxRetries: 1, + autoCaptureProviderStatus: { ready: true, mode: "opencode", issues: [] }, + autoCaptureLanguage: "en", + autoCaptureProviderOrder: ${JSON.stringify(opts.order)}, + opencodeProvider: "openai", + opencodeModel: "gpt-test", + memoryProvider: "openai-chat", + memoryModel: "gpt-4o-mini", + memoryApiUrl: "https://api.example.com/v1", + memoryApiKey: "sk-test", + showAutoCaptureToasts: false, + showErrorToasts: false, + }, +})); + +mock.module(${JSON.stringify(clientUrl)}, () => ({ + memoryClient: { + listMemories: async () => ({ success: true, memories: [] }), + addMemory: async (content) => ({ success: true, id: "mem-1", content }), + close() {}, + }, +})); + +mock.module(${JSON.stringify(tagsUrl)}, () => ({ + getTags: () => ({ + project: { + tag: "opencode_project_test", + displayName: "Test Project", + userName: "Test User", + userEmail: "test@example.com", + projectPath: "/workspace", + projectName: "workspace", + gitRepoUrl: undefined, + }, + }), +})); + +const prompt = { + id: "prompt-1", + sessionId: "session-1", + messageId: "msg-1", + projectPath: "/workspace", + content: "Fix the login bug", + createdAt: 1, + captured: false, + claimed: false, + capture_attempts: 0, + providerId: "openai", + modelId: "gpt-test", +}; + +mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ + userPromptManager: { + getLastUncapturedPrompt() { return prompt; }, + getUncapturedPromptsForSession() { return [prompt]; }, + claimPrompt() { return true; }, + releaseClaim() {}, + deletePrompt() {}, + markAsCaptured() {}, + linkMemoryToPrompt() {}, + recordFailedAttempt() {}, + }, +})); + +mock.module(${JSON.stringify(loggerUrl)}, () => ({ + log: (message, data) => logs.push({ message, data }), + serializeError: (error) => ({ + error: error instanceof Error ? error.message : String(error), + name: error instanceof Error ? error.name : undefined, + }), +})); + +mock.module(${JSON.stringify(languageUrl)}, () => ({ + detectLanguage: () => "en", + getLanguageName: () => "English", +})); + +mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ + loadOpencodeProvider: async () => ({ + ensureProviderConnected: async () => true, + getV2Client: () => ({}), + generateStructuredOutput: async () => { + channelCalls.push("opencode"); + if (${JSON.stringify(Boolean(opts.failOpencode))}) { + throw new Error("opencode channel failed"); + } + return { summary: "from-opencode", type: "bug-fix", tags: ["auth"] }; + }, + }), +})); + +mock.module(${JSON.stringify(providerConfigUrl)}, () => ({ + buildMemoryProviderConfig: () => ({}), +})); + +mock.module(${JSON.stringify(aiProviderFactoryUrl)}, () => ({ + AIProviderFactory: { + createProvider: () => ({ + executeToolCall: async () => { + channelCalls.push("external"); + if (${JSON.stringify(Boolean(opts.failExternal))}) { + return { success: false, error: "external channel failed" }; + } + return { + success: true, + data: { summary: "from-external", type: "bug-fix", tags: ["auth"] }, + }; + }, + }), + cleanupExpiredSessions: async () => 0, + }, +})); + +const { performAutoCapture } = await import(${JSON.stringify(autoCaptureUrl)}); +await performAutoCapture( + { + client: { + session: { + messages: async () => ({ + data: [ + { info: { id: "msg-1", role: "user" }, parts: [{ type: "text", text: "Fix the login bug" }] }, + { info: { id: "assistant-1", role: "assistant" }, parts: [{ type: "text", text: "Fixed auth.ts" }] }, + ], + }), + }, + tui: { showToast: async () => ({}) }, + }, + }, + "session-1", + "/workspace" +); + +console.log(JSON.stringify({ channelCalls, logs: logs.map((l) => l.message) })); +`; + + writeFileSync(scriptPath, script); + + 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(); + + return { + exitCode: result.exitCode, + stdout, + stderr, + parsed: stdout ? JSON.parse(stdout) : null, + }; +} + +describe("auto-capture provider order", () => { + it("tries opencode first by default order", () => { + const result = runOrderScenario({ order: "opencode-first" }); + expect(result.exitCode).toBe(0); + expect(result.parsed.channelCalls).toEqual(["opencode"]); + }); + + it("tries external first when configured", () => { + const result = runOrderScenario({ order: "external-first" }); + expect(result.exitCode).toBe(0); + expect(result.parsed.channelCalls).toEqual(["external"]); + }); + + it("falls back from opencode to external", () => { + const result = runOrderScenario({ order: "opencode-first", failOpencode: true }); + expect(result.exitCode).toBe(0); + expect(result.parsed.channelCalls).toEqual(["opencode", "external"]); + expect(result.parsed.logs.some((m: string) => m.includes("falling back to external API"))).toBe( + true + ); + }); + + it("falls back from external to opencode", () => { + const result = runOrderScenario({ order: "external-first", failExternal: true }); + expect(result.exitCode).toBe(0); + expect(result.parsed.channelCalls).toEqual(["external", "opencode"]); + expect( + result.parsed.logs.some((m: string) => m.includes("falling back to opencode provider")) + ).toBe(true); + }); +}); diff --git a/tests/auto-capture.test.ts b/tests/auto-capture.test.ts index 310896a6..210cea39 100644 --- a/tests/auto-capture.test.ts +++ b/tests/auto-capture.test.ts @@ -150,7 +150,7 @@ mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ }, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(languageUrl)}, () => ({ detectLanguage: () => "en", getLanguageName: () => "English", @@ -279,7 +279,7 @@ mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ }, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(languageUrl)}, () => ({ detectLanguage: () => "en", getLanguageName: () => "English", @@ -430,7 +430,7 @@ mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ }, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(languageUrl)}, () => ({ detectLanguage: () => "en", getLanguageName: () => "English", diff --git a/tests/cleanup-ai-sessions.test.ts b/tests/cleanup-ai-sessions.test.ts new file mode 100644 index 00000000..e4f3943f --- /dev/null +++ b/tests/cleanup-ai-sessions.test.ts @@ -0,0 +1,125 @@ +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[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const cleanupServiceUrl = new URL("../src/services/cleanup-service.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 readyUrl = new URL("../src/services/turso/ready.js", import.meta.url).href; +const shardManagerUrl = new URL("../src/services/turso/shard-manager.js", import.meta.url).href; +const vectorSearchUrl = new URL("../src/services/turso/vector-search.js", import.meta.url).href; +const connectionManagerUrl = new URL("../src/services/turso/connection-manager.js", import.meta.url) + .href; +const promptManagerUrl = new URL( + "../src/services/user-prompt/user-prompt-manager.js", + import.meta.url +).href; +const aiProviderFactoryUrl = new URL("../src/services/ai/ai-provider-factory.js", import.meta.url) + .href; + +function runCleanupScenario() { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-cleanup-ai-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + + const script = ` +import { mock } from "bun:test"; + +let cleanupExpiredCalls = 0; + +mock.module(${JSON.stringify(configUrl)}, () => ({ + CONFIG: { + autoCleanupEnabled: true, + autoCleanupRetentionDays: 30, + storagePath: ${JSON.stringify(dir)}, + }, +})); + +mock.module(${JSON.stringify(loggerUrl)}, () => ({ + log: () => {}, + serializeError: (error) => ({ error: String(error) }), +})); + +mock.module(${JSON.stringify(readyUrl)}, () => ({ + ensureTursoReady: async () => {}, +})); + +mock.module(${JSON.stringify(shardManagerUrl)}, () => ({ + tursoShardManager: { + getAllShards: async () => [], + decrementVectorCount: async () => {}, + }, +})); + +mock.module(${JSON.stringify(vectorSearchUrl)}, () => ({ + tursoVectorSearch: { + deleteVector: async () => {}, + }, +})); + +mock.module(${JSON.stringify(connectionManagerUrl)}, () => ({ + tursoConnectionManager: { + getConnection: async () => ({ + all: async () => [], + }), + }, +})); + +mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ + userPromptManager: { + deleteOldPrompts: async () => ({ deleted: 0, linkedMemoryIds: [] }), + vacuum: async () => {}, + }, +})); + +mock.module(${JSON.stringify(aiProviderFactoryUrl)}, () => ({ + AIProviderFactory: { + cleanupExpiredSessions: async () => { + cleanupExpiredCalls += 1; + return 7; + }, + }, +})); + +const { cleanupService } = await import(${JSON.stringify(cleanupServiceUrl)}); +const result = await cleanupService.runCleanup(); +console.log(JSON.stringify({ result, cleanupExpiredCalls })); +`; + + writeFileSync(scriptPath, script); + + 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(); + + return { + exitCode: result.exitCode, + stdout, + stderr, + parsed: stdout ? JSON.parse(stdout) : null, + }; +} + +describe("cleanup AI sessions", () => { + it("calls cleanupExpiredSessions during runCleanup", () => { + const result = runCleanupScenario(); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + expect(result.parsed.cleanupExpiredCalls).toBe(1); + expect(result.parsed.result.aiSessionsDeleted).toBe(7); + }); +}); diff --git a/tests/client-scope-traversal.test.ts b/tests/client-scope-traversal.test.ts index e9026ddb..506db1bf 100644 --- a/tests/client-scope-traversal.test.ts +++ b/tests/client-scope-traversal.test.ts @@ -73,6 +73,7 @@ mock.module(${JSON.stringify(configUrl)}, () => ({ mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), })); const { memoryClient } = await import(${JSON.stringify(clientUrl)}); diff --git a/tests/compaction-agent-preservation.test.ts b/tests/compaction-agent-preservation.test.ts index 378b95ce..0a01be69 100644 --- a/tests/compaction-agent-preservation.test.ts +++ b/tests/compaction-agent-preservation.test.ts @@ -198,7 +198,7 @@ mock.module(${JSON.stringify(webServerUrl)}, () => ({ startWebServer: async () => null, WebServer: class {}, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(languageUrl)}, () => ({ getLanguageName: () => "English" })); const mockClient = { diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index a0699642..e204cb33 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -284,6 +284,30 @@ describe("project-scoped config resolution", () => { expect(CONFIG.opencodeTimeoutMs).toBe(90000); }); + it("parses autoCaptureProviderOrder with default fallback", () => { + existsSpy = spyOn(fs, "existsSync").mockImplementation((p) => + normalizePath(p).includes(".config/opencode/opencode-mem") + ); + readSpy = spyOn(fs, "readFileSync"); + + readSpy.mockReturnValue(JSON.stringify({ autoCaptureProviderOrder: "external-first" }) as any); + initConfig("/some/project"); + expect(CONFIG.autoCaptureProviderOrder).toBe("external-first"); + + readSpy.mockReturnValue(JSON.stringify({ autoCaptureProviderOrder: "opencode-first" }) as any); + initConfig("/some/project"); + expect(CONFIG.autoCaptureProviderOrder).toBe("opencode-first"); + + // Invalid / unset fall back to the historical default. + readSpy.mockReturnValue(JSON.stringify({ autoCaptureProviderOrder: "bogus" }) as any); + initConfig("/some/project"); + expect(CONFIG.autoCaptureProviderOrder).toBe("opencode-first"); + + readSpy.mockReturnValue(JSON.stringify({}) as any); + initConfig("/some/project"); + expect(CONFIG.autoCaptureProviderOrder).toBe("opencode-first"); + }); + it("parses chatMessage.captureChildSessions from the global config", () => { existsSpy = spyOn(fs, "existsSync").mockImplementation((p) => normalizePath(p).includes(".config/opencode/opencode-mem") diff --git a/tests/fixtures/child-session-capture.mjs b/tests/fixtures/child-session-capture.mjs index f245f5a4..b8a150b4 100644 --- a/tests/fixtures/child-session-capture.mjs +++ b/tests/fixtures/child-session-capture.mjs @@ -58,7 +58,10 @@ mock.module(moduleUrl("services/user-prompt/user-prompt-manager"), () => ({ setPromptModel: async () => {}, }, })); -mock.module(moduleUrl("services/logger"), () => ({ log() {} })); +mock.module(moduleUrl("services/logger"), () => ({ + log() {}, + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), +})); mock.module(moduleUrl("services/web-server"), () => ({ startWebServer: async () => null, WebServer: class {}, diff --git a/tests/fixtures/profile-learning-memory-writer.mjs b/tests/fixtures/profile-learning-memory-writer.mjs index 96c8cea3..4da6971b 100644 --- a/tests/fixtures/profile-learning-memory-writer.mjs +++ b/tests/fixtures/profile-learning-memory-writer.mjs @@ -72,7 +72,10 @@ mock.module(configUrl, () => ({ })); const loggerUrl = new URL("../../src/services/logger.js", import.meta.url).href; -mock.module(loggerUrl, () => ({ log: () => {} })); +mock.module(loggerUrl, () => ({ + log: () => {}, + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), +})); const counts = { add: 0, readback: 0, search: 0, list: 0 }; let addedId = null; diff --git a/tests/fixtures/profile-learning-service-worker.mjs b/tests/fixtures/profile-learning-service-worker.mjs index b759d6a5..190fc4e2 100644 --- a/tests/fixtures/profile-learning-service-worker.mjs +++ b/tests/fixtures/profile-learning-service-worker.mjs @@ -100,6 +100,7 @@ mock.module(tagsUrl, () => ({ // off the lock rather than silently doing nothing. mock.module(loggerUrl, () => ({ log: (label, data) => event("log", { label, data }), + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), })); mock.module(promptManagerUrl, () => ({ diff --git a/tests/fixtures/profile-learning-storage-error-worker.mjs b/tests/fixtures/profile-learning-storage-error-worker.mjs index 19c460b5..8a7983c0 100644 --- a/tests/fixtures/profile-learning-storage-error-worker.mjs +++ b/tests/fixtures/profile-learning-storage-error-worker.mjs @@ -127,6 +127,7 @@ mock.module(tagsUrl, () => ({ mock.module(loggerUrl, () => ({ log: (label, data) => event("log", { label, data }), + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), })); mock.module(promptManagerUrl, () => ({ diff --git a/tests/fixtures/v2-memory-context.mjs b/tests/fixtures/v2-memory-context.mjs index 8a60afa6..9af7a00a 100644 --- a/tests/fixtures/v2-memory-context.mjs +++ b/tests/fixtures/v2-memory-context.mjs @@ -55,7 +55,10 @@ mock.module(moduleUrl("services/user-prompt/user-prompt-manager"), () => ({ setPromptModel: async () => {}, }, })); -mock.module(moduleUrl("services/logger"), () => ({ log() {} })); +mock.module(moduleUrl("services/logger"), () => ({ + log() {}, + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), +})); mock.module(moduleUrl("services/web-server"), () => ({ startWebServer: async () => null, WebServer: class {}, diff --git a/tests/fixtures/v2-provider-refresh.mjs b/tests/fixtures/v2-provider-refresh.mjs index e5459937..84a5dc92 100644 --- a/tests/fixtures/v2-provider-refresh.mjs +++ b/tests/fixtures/v2-provider-refresh.mjs @@ -142,6 +142,7 @@ mock.module(moduleUrl("services/user-prompt/user-prompt-manager"), () => ({ mock.module(moduleUrl("services/logger"), () => ({ log: (...args) => logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")), + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), })); mock.module(moduleUrl("services/web-server"), () => ({ diff --git a/tests/memory-portability-tool.test.ts b/tests/memory-portability-tool.test.ts index 686dbd9f..81d2bbb6 100644 --- a/tests/memory-portability-tool.test.ts +++ b/tests/memory-portability-tool.test.ts @@ -93,7 +93,7 @@ mock.module(${JSON.stringify(webServerUrl)}, () => ({ startWebServer: async () => null, WebServer: class {}, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(languageUrl)}, () => ({ getLanguageName: () => "English" })); const { OpenCodeMemPlugin } = await import(${JSON.stringify(indexUrl)}); diff --git a/tests/profile-learning-idle.test.ts b/tests/profile-learning-idle.test.ts index a934202c..da510bc8 100644 --- a/tests/profile-learning-idle.test.ts +++ b/tests/profile-learning-idle.test.ts @@ -88,7 +88,7 @@ mock.module(${JSON.stringify(privacyUrl)}, () => ({ isFullyPrivate: () => false, })); mock.module(${JSON.stringify(promptManagerUrl)}, () => ({ userPromptManager: { savePrompt() {} } })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(languageUrl)}, () => ({ getLanguageName: () => "English" })); // The web server only starts once the Turso readiness gate passes, and that // gate is what decides whether an owner exists at all. diff --git a/tests/serialize-error.test.ts b/tests/serialize-error.test.ts new file mode 100644 index 00000000..6016ef01 --- /dev/null +++ b/tests/serialize-error.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "bun:test"; +import { serializeError } from "../src/services/logger.js"; + +describe("serializeError", () => { + it("includes message, name, and stack for Error instances", () => { + const err = new Error("timed out"); + const serialized = serializeError(err); + expect(serialized.error).toBe("timed out"); + expect(serialized.name).toBe("Error"); + expect(serialized.stack).toContain("timed out"); + }); + + it("includes cause message when present", () => { + const cause = new Error("db busy"); + const err = new Error("Memory persistence failed", { cause }); + const serialized = serializeError(err); + expect(serialized.cause).toBe("db busy"); + }); + + it("stringifies non-Error values", () => { + expect(serializeError("plain")).toEqual({ error: "plain" }); + }); +}); diff --git a/tests/tag-migration-skip-tagged.test.ts b/tests/tag-migration-skip-tagged.test.ts index 2e8214f7..55ef1808 100644 --- a/tests/tag-migration-skip-tagged.test.ts +++ b/tests/tag-migration-skip-tagged.test.ts @@ -110,6 +110,7 @@ mock.module(${JSON.stringify(userPromptManagerUrl)}, () => ({ mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), })); mock.module(${JSON.stringify(configUrl)}, () => ({ diff --git a/tests/tag-migration-soft-failure.test.ts b/tests/tag-migration-soft-failure.test.ts index ac74f84a..78498c12 100644 --- a/tests/tag-migration-soft-failure.test.ts +++ b/tests/tag-migration-soft-failure.test.ts @@ -104,6 +104,7 @@ mock.module(${JSON.stringify(userPromptManagerUrl)}, () => ({ mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, + serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }), })); mock.module(${JSON.stringify(configUrl)}, () => ({ diff --git a/tests/tool-scope.test.ts b/tests/tool-scope.test.ts index d30a159c..9b822cfa 100644 --- a/tests/tool-scope.test.ts +++ b/tests/tool-scope.test.ts @@ -96,7 +96,7 @@ mock.module(${JSON.stringify(webServerUrl)}, () => ({ startWebServer: async () => null, WebServer: class {}, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(languageUrl)}, () => ({ getLanguageName: () => "English" })); const { OpenCodeMemPlugin } = await import(${JSON.stringify(indexUrl)}); diff --git a/tests/user-profile-learning-error.test.ts b/tests/user-profile-learning-error.test.ts index ac592a38..0fd01b66 100644 --- a/tests/user-profile-learning-error.test.ts +++ b/tests/user-profile-learning-error.test.ts @@ -89,7 +89,7 @@ mock.module(${JSON.stringify(profileManagerUrl)}, () => ({ }, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ loadOpencodeProvider: async () => ({ diff --git a/tests/user-profile-learning-language.test.ts b/tests/user-profile-learning-language.test.ts index 50976602..1110c7f2 100644 --- a/tests/user-profile-learning-language.test.ts +++ b/tests/user-profile-learning-language.test.ts @@ -105,7 +105,7 @@ mock.module(${JSON.stringify(profileManagerUrl)}, () => ({ }, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); let capturedSystemPrompt = null; let capturedUserPrompt = null; diff --git a/tests/user-profile-learning-prompt-mode.test.ts b/tests/user-profile-learning-prompt-mode.test.ts index 81568507..3255c538 100644 --- a/tests/user-profile-learning-prompt-mode.test.ts +++ b/tests/user-profile-learning-prompt-mode.test.ts @@ -113,7 +113,7 @@ mock.module(${JSON.stringify(profileManagerUrl)}, () => ({ }, })); -mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} })); +mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {}, serializeError: (error) => ({ error: error instanceof Error ? error.message : String(error) }) })); let structuredSystemPrompt = null; let externalSystemPrompt = null;