Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/services/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,8 @@ async function generateSummary(
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 {
Expand Down Expand Up @@ -572,6 +574,7 @@ CAPTURE if: code changed, bug fixed, feature added, decision made`;
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),
});
Expand All @@ -580,6 +583,9 @@ CAPTURE if: code changed, bug fixed, feature added, decision made`;

// Existing manual config path
if (!CONFIG.memoryModel || !CONFIG.memoryApiUrl) {
if (opencodeProviderError) {
throw opencodeProviderError;
}
throw new Error("External API not configured for auto-capture");
}

Expand Down
129 changes: 129 additions & 0 deletions tests/auto-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,122 @@ console.log(
};
}

function runProviderFailureScenario() {
const dir = mkdtempSync(join(tmpdir(), "opencode-mem-auto-capture-error-"));
tempDirs.push(dir);
const scriptPath = join(dir, "scenario.mjs");

const script = `
import { mock } from "bun:test";

const toasts = [];
let failedAttempts = 0;
let released = false;

mock.module(${JSON.stringify(configUrl)}, () => ({
CONFIG: {
autoCaptureMaxRetries: 1,
autoCaptureProviderStatus: { ready: true, mode: "opencode", issues: [] },
autoCaptureLanguage: "en",
opencodeProvider: "opencode-go",
opencodeModel: "deepseek-v4-flash",
showAutoCaptureToasts: false,
showErrorToasts: true,
},
}));

mock.module(${JSON.stringify(clientUrl)}, () => ({
memoryClient: {
listMemories: async () => ({ success: true, memories: [] }),
addMemory: async () => ({ success: true, id: "unexpected" }),
close() {},
},
}));

mock.module(${JSON.stringify(tagsUrl)}, () => ({
getTags: () => ({
project: {
tag: "opencode_project_test",
displayName: "Test Project",
projectPath: "/workspace",
},
}),
}));

mock.module(${JSON.stringify(promptManagerUrl)}, () => ({
userPromptManager: {
getUncapturedPromptsForSession: async () => [{
id: "prompt-error",
sessionId: "session-error",
messageId: "msg-error",
projectPath: "/workspace",
content: "Implement the requested change",
createdAt: 1,
captured: false,
capture_attempts: 0,
}],
claimPrompt: async () => true,
recordFailedAttempt: async () => { failedAttempts += 1; },
releaseClaim: async () => { released = true; return true; },
},
}));

mock.module(${JSON.stringify(loggerUrl)}, () => ({ log: () => {} }));
mock.module(${JSON.stringify(languageUrl)}, () => ({
detectLanguage: () => "en",
getLanguageName: () => "English",
}));
mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({
loadOpencodeProvider: async () => ({
isProviderConnected: () => true,
getV2Client: () => ({}),
generateStructuredOutput: async () => {
throw new Error(
"opencode-mem: opencode reported APIError: Thinking mode does not support this tool_choice"
);
},
}),
}));

const { performAutoCapture } = await import(${JSON.stringify(autoCaptureUrl)});
await performAutoCapture(
{
client: {
session: {
messages: async () => ({
data: [
{ info: { id: "msg-error", role: "user" }, parts: [{ type: "text", text: "Implement the requested change" }] },
{ info: { id: "assistant-error", role: "assistant" }, parts: [{ type: "text", text: "Implemented it" }] },
],
}),
},
tui: { showToast: async (toast) => { toasts.push(toast); return {}; } },
},
},
"session-error",
"/workspace"
);

console.log(JSON.stringify({ toasts, failedAttempts, released }));
`;

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,
stderr,
parsed: stdout ? JSON.parse(stdout) : null,
};
}

describe("auto-capture idle processing", () => {
it("captures all uncaptured prompts in a session in chronological response windows", () => {
const result = runScenario();
Expand All @@ -231,4 +347,17 @@ describe("auto-capture idle processing", () => {
expect(result.parsed?.summaryPrompts[0]).not.toContain("Second response");
expect(result.parsed?.summaryPrompts[1]).toContain("Second response");
});

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?.failedAttempts).toBe(1);
expect(result.parsed?.released).toBe(true);
expect(result.parsed?.toasts).toHaveLength(1);
const message = result.parsed?.toasts[0]?.body?.message ?? "";
expect(message).toContain("Thinking mode does not support");
expect(message).not.toContain("External API not configured");
});
});