import { beforeEach, describe, expect, mock, test } from 'bun:test'; type ConfigResponse = { data: Record }; (mock as unknown as { restore?: () => void }).restore?.(); const configResolvers: Array<(response: ConfigResponse) => void> = []; let configCalls = 0; let runtimeKey = 'test-runtime'; const promptAsyncCalls: unknown[][] = []; const promptAsyncResults: Array = []; const pathGetResults: Array = []; const promptAsyncMock = mock(async (...args: unknown[]) => { promptAsyncCalls.push(args); const next = promptAsyncResults.shift(); if (next instanceof Error) throw next; return next ?? { response: new Response(null, { status: 200 }) }; }); const pathGetMock = mock(async () => { const next = pathGetResults.shift(); if (next instanceof Error) throw next; return next ?? { data: { directory: '/workspace/project' } }; }); mock.module('@opencode-ai/sdk/v2', () => ({ createOpencodeClient: mock(() => ({ config: { get: mock(() => { configCalls += 1; return new Promise((resolve) => { configResolvers.push(resolve); }); }), }, session: { promptAsync: promptAsyncMock, }, path: { get: pathGetMock, }, })), })); mock.module('@/contexts/runtimeAPIRegistry', () => ({ getRegisteredRuntimeAPIs: mock(() => null), })); mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: mock(() => ({ api: (path: string) => path, })), })); mock.module('@/lib/runtime-switch', () => ({ getRuntimeApiBaseUrl: mock(() => ''), getRuntimeKey: mock(() => runtimeKey), })); mock.module('@/lib/runtime-fetch', () => ({ runtimeFetch: mock(async () => new Response(JSON.stringify([]), { headers: { 'Content-Type': 'application/json' }, })), })); mock.module('@/lib/startupTrace', () => ({ markStartupTrace: mock(() => undefined), })); const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`); beforeEach(() => { runtimeKey = 'test-runtime'; promptAsyncCalls.length = 0; promptAsyncResults.length = 0; pathGetResults.length = 0; }); describe('opencodeClient directory availability', () => { test('distinguishes a missing directory from an unavailable path probe', async () => { pathGetResults.push({ error: { code: 'ENOENT', message: 'no such file or directory' } }); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('missing'); pathGetResults.push(new Error('offline')); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); }); }); describe('opencodeClient getConfig cache', () => { test('cleared stale in-flight requests do not repopulate cache or delete newer in-flight requests', async () => { const first = opencodeClient.getConfig('/workspace/project'); expect(configCalls).toBe(1); opencodeClient.clearConfigCache(); const second = opencodeClient.getConfig('/workspace/project'); expect(configCalls).toBe(2); configResolvers[0]?.({ data: { model: 'old/model' } }); expect(await first).toEqual({ model: 'old/model' }); const third = opencodeClient.getConfig('/workspace/project'); expect(configCalls).toBe(2); configResolvers[1]?.({ data: { model: 'new/model' } }); expect(await second).toEqual({ model: 'new/model' }); expect(await third).toEqual({ model: 'new/model' }); const cached = await opencodeClient.getConfig('/workspace/project'); expect(cached).toEqual({ model: 'new/model' }); expect(configCalls).toBe(2); }); }); describe('opencodeClient prompt retry behavior', () => { const sendPrompt = (providerID = 'anthropic') => opencodeClient.sendMessage({ id: 'ses_1', providerID, modelID: 'claude-sonnet', text: 'hello', }); test('does not retry 504 prompt responses because the POST may already be accepted', async () => { promptAsyncResults.push({ response: new Response('gateway timeout', { status: 504 }) }); let error: unknown = null; try { await sendPrompt('anthropic-504'); } catch (caught) { error = caught; } expect(promptAsyncCalls.length).toBe(1); expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (504)'); }); test('does not retry transport failures because the tunnel may have lost only the response', async () => { promptAsyncResults.push(new TypeError('Failed to fetch')); let error: unknown = null; try { await sendPrompt('anthropic-network'); } catch (caught) { error = caught; } expect(promptAsyncCalls.length).toBe(1); expect(error instanceof Error ? error.message : String(error)).toContain('Failed to fetch'); }); test('does not fabricate an HTTP 500 when the SDK swallows a transport failure into result.error', async () => { // The SDK catches thrown fetch errors and returns { error, response: undefined }. // That is a transport failure, not a server 500 — it must surface as a // descriptive transport error, never as "Failed to send message (500): {}". promptAsyncResults.push({ error: new TypeError('relay tunnel reset: plaintext frame on established channel'), response: undefined }); let error: unknown = null; try { await sendPrompt('anthropic-transport'); } catch (caught) { error = caught; } expect(promptAsyncCalls.length).toBe(1); const message = error instanceof Error ? error.message : String(error); expect(message).not.toContain('Failed to send message (500)'); expect(message).toContain('transport failure'); expect(message).toContain('relay tunnel reset'); expect((error as Error & { status?: number }).status).toBe(undefined); }); test('does not retry 503 prompt responses because proxy errors can be ambiguous too', async () => { promptAsyncResults.push({ response: new Response('starting', { status: 503 }) }); let error: unknown = null; try { await sendPrompt('anthropic-503'); } catch (caught) { error = caught; } expect(promptAsyncCalls.length).toBe(1); expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (503)'); }); test('does not dispatch after the runtime changes while preparing attachments', async () => { runtimeKey = 'runtime-a'; const pending = opencodeClient.sendMessage({ id: 'ses_runtime_race', providerID: 'runtime-race-provider', modelID: 'model-a', text: 'hello', runtimeKey: 'runtime-a', files: [{ type: 'file', mime: 'text/markdown', filename: 'notes.md', url: 'data:text/markdown,hello', }], }); runtimeKey = 'runtime-b'; let error: unknown = null; try { await pending; } catch (caught) { error = caught; } expect(error).toBeInstanceOf(Error); expect(error instanceof Error ? error.message : String(error)).toContain('runtime changed'); expect(promptAsyncCalls).toHaveLength(0); }); });