import fs from 'fs'; import fsPromises from 'fs/promises'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearCache, disableCache, enableCache } from '../../src/cache'; import logger from '../../src/logger'; import { convertPermissionConfigToRuleset, FS_READONLY_TOOLS, OpenCodeSDKProvider, } from '../../src/providers/opencode-sdk'; import { createDeferred } from '../util/utils'; import type { MockInstance } from 'vitest'; import type { CallApiContextParams } from '../../src/types/index'; vi.mock('../../src/cliState', () => ({ default: { basePath: '/test/basePath' }, basePath: '/test/basePath', })); vi.mock('../../src/esm', async (importOriginal) => ({ ...(await importOriginal()), importModule: vi.fn(), })); // Mock @opencode-ai/sdk to fail on direct import, forcing fallback to smart resolution vi.mock('@opencode-ai/sdk', () => { throw new Error('Direct import blocked - use smart ESM resolution'); }); vi.mock('@opencode-ai/sdk/v2', () => { throw new Error('Direct import blocked - use smart ESM resolution'); }); // Mock node:module createRequire for ESM package resolution vi.mock('node:module', async (importOriginal) => ({ ...(await importOriginal()), createRequire: vi.fn(() => ({ resolve: vi.fn((pkg: string) => { if (pkg === '@opencode-ai/sdk/package.json') { return '/test/basePath/node_modules/@opencode-ai/sdk/package.json'; } throw new Error(`Cannot find module '${pkg}'`); }), })), })); // Mock OpenCode SDK client const mockSessionCreate = vi.fn(); const mockSessionPrompt = vi.fn(); const mockSessionMessages = vi.fn(); const mockSessionDelete = vi.fn(); const mockSessionList = vi.fn(); const mockSessionAbort = vi.fn(); // Mock server const mockServerClose = vi.fn(); // Mock createOpencode function that returns { client, server } const mockCreateOpencode = vi.fn(); const mockCreateOpencodeClient = vi.fn(); // Helper to create mock session create response // SDK returns: { id, title, version, time } const createMockSessionResponse = (id = 'test-session-123') => ({ id, title: `promptfoo-${Date.now()}`, version: 1, time: new Date().toISOString(), }); // Helper to create mock prompt response // SDK session.prompt() returns: { info: AssistantMessage, parts: Part[] } const createMockPromptResponse = ( parts: Array<{ type: string; text?: string; tool?: string; state?: { status?: string; input?: Record; metadata?: Record; }; }>, tokens?: { total?: number; input?: number; output?: number; reasoning?: number; cache?: | number | { read?: number; write?: number; }; }, cost?: number, structured?: unknown, ) => ({ data: { info: { id: 'msg-123', sessionID: 'test-session-123', role: 'assistant', parentID: 'user-msg-123', modelID: 'claude-sonnet-4-20250514', providerID: 'anthropic', mode: 'default', path: { cwd: '/test', root: '/test' }, tokens: tokens ? { input: tokens.input ?? 0, output: tokens.output ?? 0, ...(tokens.total === undefined ? {} : { total: tokens.total }), ...(tokens.reasoning === undefined ? {} : { reasoning: tokens.reasoning }), ...(tokens.cache === undefined ? {} : { cache: tokens.cache }), } : undefined, ...(cost === undefined ? {} : { cost }), structured, time: { created: Date.now() }, }, parts, }, }); describe('OpenCodeSDKProvider', () => { let tempDirSpy: MockInstance; let statSyncSpy: MockInstance; let rmSpy: MockInstance; beforeEach(async () => { vi.clearAllMocks(); // Fully reset session mocks (calls + queued implementations) so a // mockImplementationOnce in one test cannot leak into another under // random test ordering. mockSessionCreate.mockReset(); mockSessionPrompt.mockReset(); mockSessionDelete.mockReset(); mockSessionAbort.mockReset(); // Setup mock client with session.prompt() const mockClient = { session: { create: mockSessionCreate, prompt: mockSessionPrompt, messages: mockSessionMessages, delete: mockSessionDelete, list: mockSessionList, abort: mockSessionAbort, }, }; // Setup mock server const mockServer = { url: 'http://127.0.0.1:4096', close: mockServerClose, }; // Setup createOpencode mock mockCreateOpencode.mockResolvedValue({ client: mockClient, server: mockServer, }); mockCreateOpencodeClient.mockReturnValue(mockClient); // Setup importModule to return our mocks const { importModule } = await import('../../src/esm'); vi.mocked(importModule).mockResolvedValue({ createOpencode: mockCreateOpencode, createOpencodeClient: mockCreateOpencodeClient, }); // Default session mocks mockSessionCreate.mockResolvedValue(createMockSessionResponse()); mockSessionPrompt.mockResolvedValue( createMockPromptResponse( [{ type: 'text', text: 'Test response' }], { input: 10, output: 20 }, 0.001, ), ); mockSessionDelete.mockResolvedValue(undefined); mockSessionAbort.mockResolvedValue(undefined); // File system mocks tempDirSpy = vi.spyOn(fs, 'mkdtempSync').mockReturnValue('/tmp/test-temp-dir'); statSyncSpy = vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => true, mtimeMs: 1234567890, } as fs.Stats); rmSpy = vi.spyOn(fsPromises, 'rm').mockResolvedValue(undefined); vi.spyOn(fs, 'readdirSync').mockReturnValue([]); // Mock readFileSync to return package.json for SDK resolution vi.spyOn(fs, 'readFileSync').mockImplementation((filePath: fs.PathOrFileDescriptor) => { if (String(filePath).includes('@opencode-ai/sdk/package.json')) { return JSON.stringify({ name: '@opencode-ai/sdk', exports: { '.': { import: './dist/index.js', types: './dist/index.d.ts', }, './v2': { import: './dist/v2/index.js', types: './dist/v2/index.d.ts', }, }, }); } return ''; }); }); afterEach(async () => { vi.restoreAllMocks(); await clearCache(); }); describe('constructor', () => { it('should initialize with default config', () => { const provider = new OpenCodeSDKProvider(); expect(provider.config).toEqual({}); expect(provider.id()).toBe('opencode:sdk'); }); it('should initialize with custom config', () => { const config = { apiKey: 'test-key', model: 'claude-sonnet-4-20250514', provider_id: 'anthropic', }; const provider = new OpenCodeSDKProvider({ config }); expect(provider.config).toEqual(config); expect(provider.getApiKey()).toBe('test-key'); }); it('should use custom id when provided', () => { const provider = new OpenCodeSDKProvider({ id: 'custom-provider-id' }); expect(provider.id()).toBe('custom-provider-id'); }); }); describe('getApiKey', () => { it('should prioritize config apiKey', () => { const provider = new OpenCodeSDKProvider({ config: { apiKey: 'config-key' }, env: { ANTHROPIC_API_KEY: 'env-key' }, }); expect(provider.getApiKey()).toBe('config-key'); }); it('should use ANTHROPIC_API_KEY for anthropic provider', () => { const provider = new OpenCodeSDKProvider({ config: { provider_id: 'anthropic' }, env: { ANTHROPIC_API_KEY: 'anthropic-key' }, }); expect(provider.getApiKey()).toBe('anthropic-key'); }); it('should use OPENAI_API_KEY for openai provider', () => { const provider = new OpenCodeSDKProvider({ config: { provider_id: 'openai' }, env: { OPENAI_API_KEY: 'openai-key' }, }); expect(provider.getApiKey()).toBe('openai-key'); }); it('should fall back to common env vars', () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'fallback-key' }, }); expect(provider.getApiKey()).toBe('fallback-key'); }); }); describe('toString', () => { it('should return provider description', () => { const provider = new OpenCodeSDKProvider(); expect(provider.toString()).toBe('[OpenCode SDK Provider]'); }); }); describe('callApi', () => { describe('basic functionality', () => { it('should successfully call API with simple prompt', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse( [{ type: 'text', text: 'Test response' }], { input: 10, output: 20 }, 0.001, ), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.output).toBe('Test response'); expect(result.tokenUsage).toEqual({ prompt: 10, completion: 20, total: 30, }); expect(result.cost).toBe(0.001); expect(result.sessionId).toBe('test-session-123'); expect(result.error).toBeUndefined(); // Verify session.create was called with body.title expect(mockSessionCreate).toHaveBeenCalledTimes(1); expect(mockSessionCreate).toHaveBeenCalledWith( expect.objectContaining({ title: expect.stringMatching(/^promptfoo-\d+$/), }), ); // Verify session.prompt was called with the flattened v2 parameter shape expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ sessionID: 'test-session-123', parts: [{ type: 'text', text: 'Test prompt' }], }), ); }); it('should leave cost undefined when OpenCode does not report one', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse([{ type: 'text', text: 'No cost response' }], { input: 5, output: 10, }), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.cost).toBeUndefined(); }); it('should preserve reasoning and cache token details', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse( [{ type: 'text', text: 'Test response' }], { input: 10, output: 20, total: 42, reasoning: 7, cache: { read: 3, write: 2 }, }, 0.001, ), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.tokenUsage).toEqual({ prompt: 10, completion: 20, total: 42, cached: 3, completionDetails: { reasoning: 7, cacheReadInputTokens: 3, cacheCreationInputTokens: 2, }, }); }); it('should fall back to the v1 nested request shape when v2 is unavailable', async () => { const { importModule } = await import('../../src/esm'); vi.mocked(importModule).mockImplementation(async (modulePath: string) => { if (/[/\\]dist[/\\]v2[/\\]/.test(modulePath)) { throw new Error('v2 unavailable'); } return { createOpencode: mockCreateOpencode, createOpencodeClient: mockCreateOpencodeClient, }; }); const provider = new OpenCodeSDKProvider({ config: { working_dir: '/test/dir', permission: { bash: 'allow', }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionCreate).toHaveBeenCalledWith( expect.objectContaining({ body: expect.objectContaining({ title: expect.stringMatching(/^promptfoo-\d+$/), }), query: { directory: '/test/dir', }, }), ); expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ path: { id: 'test-session-123', sessionID: 'test-session-123', }, query: { directory: '/test/dir', }, body: expect.objectContaining({ parts: [{ type: 'text', text: 'Test prompt' }], permission: { bash: 'allow', }, }), }), ); expect(mockSessionDelete).toHaveBeenCalledWith( expect.objectContaining({ path: { id: 'test-session-123', sessionID: 'test-session-123', }, query: { directory: '/test/dir', }, }), ); }); it('should handle multiple text parts in response', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse([ { type: 'text', text: 'Part 1' }, { type: 'tool', text: undefined }, { type: 'text', text: 'Part 2' }, ]), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.output).toBe('Part 1\nPart 2'); }); it('should normalize first-class skill tool parts into skillCalls metadata', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse([ { type: 'tool', tool: 'skill', state: { status: 'completed', input: { name: 'review-standards' }, metadata: { name: 'review-standards', dir: '/repo/.agents/skills/review-standards', }, }, }, { type: 'text', text: 'Skill applied.' }, ]), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Use the review-standards skill'); expect(result.metadata?.skillCalls).toEqual([ { name: 'review-standards', input: { name: 'review-standards' }, path: path.join('/repo/.agents/skills/review-standards', 'SKILL.md'), source: 'tool', }, ]); }); it('should retain failed skill tool attempts as errored skillCalls', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse([ { type: 'tool', tool: 'skill', state: { status: 'error', input: { name: 'missing-skill' }, }, }, ]), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Use the missing skill'); expect(result.metadata?.skillCalls).toEqual([ { name: 'missing-skill', input: { name: 'missing-skill' }, source: 'tool', is_error: true, }, ]); }); it('should handle SDK exceptions', async () => { const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}); mockSessionPrompt.mockRejectedValue(new Error('Network error')); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.error).toBe('Error calling OpenCode SDK: Network error'); expect(errorSpy).toHaveBeenCalled(); errorSpy.mockRestore(); }); it('should handle empty parts in response', async () => { mockSessionPrompt.mockResolvedValue(createMockPromptResponse([], { input: 5, output: 10 })); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.output).toBe(''); }); it('should prefer structured payloads for json_schema output', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse( [{ type: 'text', text: '```json\n{"language":"python"}\n```' }], { input: 5, output: 10 }, 0.0005, { language: 'python', task: 'Generate Fibonacci output' }, ), ); const provider = new OpenCodeSDKProvider({ config: { format: { type: 'json_schema', schema: { type: 'object', properties: { language: { type: 'string' }, task: { type: 'string' }, }, required: ['language', 'task'], }, }, }, env: { OPENAI_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.output).toBe('{"language":"python","task":"Generate Fibonacci output"}'); }); it('should normalize fenced json text when structured payload is missing', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse( [ { type: 'text', text: '```json\n{\n "language": "python",\n "task": "Generate Fibonacci output"\n}\n```', }, ], { input: 5, output: 10 }, 0.0005, ), ); const provider = new OpenCodeSDKProvider({ config: { format: { type: 'json_schema', schema: { type: 'object', properties: { language: { type: 'string' }, task: { type: 'string' }, }, required: ['language', 'task'], }, }, }, env: { OPENAI_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.output).toBe('{"language":"python","task":"Generate Fibonacci output"}'); }); }); describe('working directory', () => { it('should use temp directory when no working_dir specified', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(tempDirSpy).toHaveBeenCalledWith(expect.stringContaining('promptfoo-opencode-sdk-')); expect(rmSpy).toHaveBeenCalledWith('/tmp/test-temp-dir', { recursive: true, force: true, }); }); it('should use specified working_dir', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: '/custom/dir' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(tempDirSpy).not.toHaveBeenCalled(); expect(statSyncSpy).toHaveBeenCalledWith('/custom/dir'); expect(mockSessionCreate).toHaveBeenCalledWith( expect.objectContaining({ directory: '/custom/dir', }), ); expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ directory: '/custom/dir', }), ); }); it('should resolve relative working_dir from cliState.basePath', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: './workspace' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); const resolvedWorkingDir = path.resolve('/test/basePath', 'workspace'); expect(statSyncSpy).toHaveBeenCalledWith(resolvedWorkingDir); expect(mockSessionCreate).toHaveBeenCalledWith( expect.objectContaining({ directory: resolvedWorkingDir, }), ); expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ directory: resolvedWorkingDir, }), ); }); it('should throw error for non-existent working_dir', async () => { statSyncSpy.mockImplementation(() => { throw new Error('ENOENT'); }); const provider = new OpenCodeSDKProvider({ config: { working_dir: '/nonexistent' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await expect(provider.callApi('Test prompt')).rejects.toThrow( /Working directory \/nonexistent .* does not exist/, ); }); it('should throw error if working_dir is not a directory', async () => { statSyncSpy.mockReturnValue({ isDirectory: () => false, mtimeMs: 1234567890, } as fs.Stats); const provider = new OpenCodeSDKProvider({ config: { working_dir: '/some/file.txt' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await expect(provider.callApi('Test prompt')).rejects.toThrow( /Working directory \/some\/file.txt .* is not a directory/, ); }); }); describe('session management', () => { it('should create new session for each call by default', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Prompt 1'); await provider.callApi('Prompt 2'); expect(mockSessionCreate).toHaveBeenCalledTimes(2); }); it('should resume session when session_id provided', async () => { const provider = new OpenCodeSDKProvider({ config: { session_id: 'existing-session' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionCreate).not.toHaveBeenCalled(); // session.prompt is called with the provided session ID expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ sessionID: 'existing-session', }), ); }); it('should reuse session when persist_sessions is true without cache', async () => { const provider = new OpenCodeSDKProvider({ config: { persist_sessions: true }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Same prompt'); await provider.callApi('Same prompt'); expect(mockSessionCreate).toHaveBeenCalledTimes(1); }); it('should delete non-persistent sessions after each call', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionDelete).toHaveBeenCalledWith({ sessionID: 'test-session-123', }); }); it('should swallow delete errors for non-persistent sessions', async () => { const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {}); mockSessionDelete.mockRejectedValue(new Error('delete failed')); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.output).toBe('Test response'); expect(debugSpy).toHaveBeenCalledWith( expect.stringContaining('Failed to delete non-persistent session test-session-123'), ); debugSpy.mockRestore(); }); }); describe('abort handling', () => { it('should return error when aborted before start', async () => { const abortController = new AbortController(); abortController.abort(); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt', undefined, { abortSignal: abortController.signal, }); expect(result.error).toBe('OpenCode SDK call aborted before it started'); expect(mockSessionCreate).not.toHaveBeenCalled(); }); it('should handle AbortError during call', async () => { const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); const abortError = new Error('Aborted'); abortError.name = 'AbortError'; mockSessionPrompt.mockRejectedValue(abortError); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt'); expect(result.error).toBe('OpenCode SDK call aborted'); expect(warnSpy).toHaveBeenCalledWith('OpenCode SDK call aborted'); warnSpy.mockRestore(); }); }); describe('caching', () => { beforeEach(async () => { await enableCache(); }); afterEach(async () => { await disableCache(); }); it('should cache responses', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse( [{ type: 'text', text: 'Cached response' }], { input: 5, output: 10 }, 0.0005, ), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // First call const result1 = await provider.callApi('Test prompt'); expect(result1.output).toBe('Cached response'); // Second call should be cached const result2 = await provider.callApi('Test prompt'); expect(result2.output).toBe('Cached response'); // Only one actual API call expect(mockSessionPrompt).toHaveBeenCalledTimes(1); }); it('should disable caching when MCP is configured', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse([{ type: 'text', text: 'Response' }]), ); const provider = new OpenCodeSDKProvider({ config: { mcp: { weather: { type: 'local', command: ['npx', '-y', '@h1deya/mcp-server-weather'], }, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // Both calls should hit the API since caching is disabled with MCP await provider.callApi('Test prompt'); await provider.callApi('Test prompt'); expect(mockSessionPrompt).toHaveBeenCalledTimes(2); }); it('should cache MCP responses when cache_mcp is true', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse([{ type: 'text', text: 'MCP cached response' }]), ); const provider = new OpenCodeSDKProvider({ config: { cache_mcp: true, mcp: { weather: { type: 'local', command: ['npx', '-y', '@h1deya/mcp-server-weather'], }, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // First call - should hit the API const result1 = await provider.callApi('Test prompt'); expect(result1.output).toBe('MCP cached response'); expect(mockSessionPrompt).toHaveBeenCalledTimes(1); // Second call with same prompt - should use cache const result2 = await provider.callApi('Test prompt'); expect(result2.output).toBe('MCP cached response'); expect(mockSessionPrompt).toHaveBeenCalledTimes(1); }); it('should produce different cache keys for different MCP configs when cache_mcp is true', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse([{ type: 'text', text: 'Response A' }]), ); const providerA = new OpenCodeSDKProvider({ config: { cache_mcp: true, mcp: { weather: { type: 'local', command: ['npx', '-y', '@h1deya/mcp-server-weather'], }, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // First provider call await providerA.callApi('Test prompt'); expect(mockSessionPrompt).toHaveBeenCalledTimes(1); mockSessionPrompt.mockResolvedValue( createMockPromptResponse([{ type: 'text', text: 'Response B' }]), ); const providerB = new OpenCodeSDKProvider({ config: { cache_mcp: true, mcp: { search: { type: 'local', command: ['npx', '-y', '@other/mcp-server-search'], }, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // Second provider with different MCP config - should NOT use cache from first const result = await providerB.callApi('Test prompt'); expect(result.output).toBe('Response B'); expect(mockSessionPrompt).toHaveBeenCalledTimes(2); }); it('should produce different cache keys for different session_id values', async () => { mockSessionPrompt.mockResolvedValueOnce( createMockPromptResponse([{ type: 'text', text: 'Response from session A' }]), ); const providerForSessionA = new OpenCodeSDKProvider({ config: { session_id: 'session-A' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const resultFromSessionA = await providerForSessionA.callApi('Test prompt'); expect(resultFromSessionA.output).toBe('Response from session A'); const cachedResultFromSessionA = await providerForSessionA.callApi('Test prompt'); expect(cachedResultFromSessionA.output).toBe('Response from session A'); expect(mockSessionPrompt).toHaveBeenCalledTimes(1); mockSessionPrompt.mockResolvedValueOnce( createMockPromptResponse([{ type: 'text', text: 'Response from session B' }]), ); const providerForSessionB = new OpenCodeSDKProvider({ config: { session_id: 'session-B' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const resultFromSessionB = await providerForSessionB.callApi('Test prompt'); expect(resultFromSessionB.output).toBe('Response from session B'); expect(mockSessionPrompt).toHaveBeenCalledTimes(2); }); it('should produce different cache keys for different parent_session_id values', async () => { mockSessionPrompt.mockResolvedValueOnce( createMockPromptResponse([{ type: 'text', text: 'Response from parent A' }]), ); const providerForParentA = new OpenCodeSDKProvider({ config: { parent_session_id: 'parent-A' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const resultFromParentA = await providerForParentA.callApi('Test prompt'); expect(resultFromParentA.output).toBe('Response from parent A'); const cachedResultFromParentA = await providerForParentA.callApi('Test prompt'); expect(cachedResultFromParentA.output).toBe('Response from parent A'); expect(mockSessionPrompt).toHaveBeenCalledTimes(1); mockSessionPrompt.mockResolvedValueOnce( createMockPromptResponse([{ type: 'text', text: 'Response from parent B' }]), ); const providerForParentB = new OpenCodeSDKProvider({ config: { parent_session_id: 'parent-B' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const resultFromParentB = await providerForParentB.callApi('Test prompt'); expect(resultFromParentB.output).toBe('Response from parent B'); expect(mockSessionPrompt).toHaveBeenCalledTimes(2); }); it('should bust cache when context.bustCache is true', async () => { mockSessionPrompt.mockResolvedValue( createMockPromptResponse([{ type: 'text', text: 'Fresh response' }]), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // First call await provider.callApi('Test prompt'); // Second call with bustCache const context: CallApiContextParams = { bustCache: true, prompt: { raw: 'Test prompt', label: 'test' }, vars: {}, }; await provider.callApi('Test prompt', context); // Both calls should hit the API expect(mockSessionPrompt).toHaveBeenCalledTimes(2); }); }); describe('config merging', () => { it('should merge prompt config with provider config', async () => { const provider = new OpenCodeSDKProvider({ config: { model: 'default-model' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const context: CallApiContextParams = { prompt: { raw: 'Test', label: 'test', config: { model: 'prompt-model' }, }, vars: {}, }; await provider.callApi('Test prompt', context); // Prompt config should override provider config // The merged config should use 'prompt-model' expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ model: expect.objectContaining({ modelID: 'prompt-model', }), }), ); }); }); describe('model and provider config', () => { it('should pass model config to prompt body', async () => { const provider = new OpenCodeSDKProvider({ config: { provider_id: 'anthropic', model: 'claude-sonnet-4-20250514', }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); // SDK expects model: { providerID, modelID } in the body expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ sessionID: 'test-session-123', model: { providerID: 'anthropic', modelID: 'claude-sonnet-4-20250514', }, }), ); }); it('should work without model config', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); // Should still call prompt without model config expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ sessionID: 'test-session-123', parts: [{ type: 'text', text: 'Test prompt' }], }), ); }); }); }); describe('cleanup', () => { it('should close server on cleanup', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // Make a call to initialize server await provider.callApi('Test prompt'); // Cleanup await provider.cleanup(); expect(mockServerClose).toHaveBeenCalled(); }); it('should delete tracked persistent sessions on cleanup', async () => { const provider = new OpenCodeSDKProvider({ config: { persist_sessions: true }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); await provider.cleanup(); expect(mockSessionDelete).toHaveBeenCalledWith({ sessionID: 'test-session-123', }); }); }); describe('buildToolsConfig', () => { it('should disable all tools when no working_dir', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // Access private method through callApi behavior await provider.callApi('Test prompt'); // Temp dir should be created and cleaned up expect(tempDirSpy).toHaveBeenCalled(); expect(rmSpy).toHaveBeenCalled(); }); it('should enable read-only tools with working_dir', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: '/test/dir' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); // No temp dir should be created expect(tempDirSpy).not.toHaveBeenCalled(); // Verify tools config includes read-only tools expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ read: true, grep: true, glob: true, list: true, bash: false, write: false, edit: false, }), }), ); }); it('should use explicit tools config when provided', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: '/test/dir', tools: { read: true, write: true, bash: true, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ tools: { read: true, write: true, bash: true, }, }), ); }); }); describe('existing server connection', () => { it('should connect to existing server via baseUrl', async () => { const provider = new OpenCodeSDKProvider({ config: { baseUrl: 'http://localhost:8080' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); // Should use createOpencodeClient instead of createOpencode expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: 'http://localhost:8080', }); expect(mockCreateOpencode).not.toHaveBeenCalled(); }); }); describe('FS_READONLY_TOOLS constant', () => { it('should contain expected read-only tools', () => { expect(FS_READONLY_TOOLS).toEqual(['glob', 'grep', 'list', 'read']); }); it('should be sorted alphabetically', () => { const sorted = [...FS_READONLY_TOOLS].sort(); expect(FS_READONLY_TOOLS).toEqual(sorted); }); }); describe('convertPermissionConfigToRuleset', () => { it('returns undefined for undefined input', () => { expect(convertPermissionConfigToRuleset(undefined)).toBeUndefined(); }); it('returns undefined for an empty config', () => { expect(convertPermissionConfigToRuleset({})).toBeUndefined(); }); it('skips keys whose value is undefined', () => { expect(convertPermissionConfigToRuleset({ bash: undefined, edit: 'allow' })).toEqual([ { permission: 'edit', pattern: '*', action: 'allow' }, ]); }); it('expands simple string values into a wildcard rule', () => { expect( convertPermissionConfigToRuleset({ bash: 'allow', webfetch: 'deny', }), ).toEqual([ { permission: 'bash', pattern: '*', action: 'allow' }, { permission: 'webfetch', pattern: '*', action: 'deny' }, ]); }); it('expands pattern objects into one rule per pattern', () => { expect( convertPermissionConfigToRuleset({ bash: { 'git *': 'allow', '*': 'ask' }, }), ).toEqual([ { permission: 'bash', pattern: 'git *', action: 'allow' }, { permission: 'bash', pattern: '*', action: 'ask' }, ]); }); it('handles a mix of simple and pattern-based entries', () => { const ruleset = convertPermissionConfigToRuleset({ bash: 'ask', edit: { '*.md': 'allow', 'src/**': 'deny' }, }); expect(ruleset).toEqual([ { permission: 'bash', pattern: '*', action: 'ask' }, { permission: 'edit', pattern: '*.md', action: 'allow' }, { permission: 'edit', pattern: 'src/**', action: 'deny' }, ]); }); }); describe('new tools configuration', () => { it('should include question, skill, lsp tools in disabled mode by default', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); // Verify tools config includes new tools (disabled) expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ question: false, skill: false, lsp: false, }), }), ); }); it('should allow enabling new tools explicitly', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: '/test/dir', tools: { read: true, question: true, skill: true, lsp: true, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ tools: { read: true, question: true, skill: true, lsp: true, }, }), ); }); }); describe('new permission types', () => { it('should convert simple permissions into a v2 rule array on session.create', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: '/test/dir', permission: { bash: 'allow', doom_loop: 'deny', external_directory: 'deny', }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionCreate).toHaveBeenCalledWith( expect.objectContaining({ permission: expect.arrayContaining([ { permission: 'bash', pattern: '*', action: 'allow' }, { permission: 'doom_loop', pattern: '*', action: 'deny' }, { permission: 'external_directory', pattern: '*', action: 'deny' }, ]), }), ); const createCall = mockSessionCreate.mock.calls[0][0]; expect(createCall.permission).toHaveLength(3); }); it('should expand pattern-based permissions into one rule per pattern', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: '/test/dir', permission: { bash: { 'git *': 'allow', 'rm *': 'deny', '*': 'ask', }, edit: { '*.md': 'allow', 'src/**': 'ask', }, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionCreate).toHaveBeenCalledWith( expect.objectContaining({ permission: expect.arrayContaining([ { permission: 'bash', pattern: 'git *', action: 'allow' }, { permission: 'bash', pattern: 'rm *', action: 'deny' }, { permission: 'bash', pattern: '*', action: 'ask' }, { permission: 'edit', pattern: '*.md', action: 'allow' }, { permission: 'edit', pattern: 'src/**', action: 'ask' }, ]), }), ); const createCall = mockSessionCreate.mock.calls[0][0]; expect(createCall.permission).toHaveLength(5); }); it('should omit permission from session.create when no rules are provided', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: '/test/dir', permission: {}, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); const createCall = mockSessionCreate.mock.calls[0][0]; expect(createCall).not.toHaveProperty('permission'); }); }); describe('updated api support', () => { it('should pass workspace through create and prompt queries', async () => { const provider = new OpenCodeSDKProvider({ config: { working_dir: '/test/dir', workspace: 'feature-branch', }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionCreate).toHaveBeenCalledWith( expect.objectContaining({ directory: '/test/dir', workspace: 'feature-branch', }), ); expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ directory: '/test/dir', workspace: 'feature-branch', }), ); }); it('should require working_dir or baseUrl when workspace is configured', async () => { const provider = new OpenCodeSDKProvider({ config: { workspace: 'feature-branch' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await expect(provider.callApi('Test prompt')).rejects.toThrow( 'OpenCode SDK workspace support requires either baseUrl or working_dir', ); }); it('should pass JSON schema format and variant to prompt body', async () => { const provider = new OpenCodeSDKProvider({ config: { provider_id: 'openai', model: 'gpt-4o-mini', format: { type: 'json_schema', schema: { type: 'object', properties: { answer: { type: 'string' }, }, required: ['answer'], }, retryCount: 2, }, variant: 'fast', }, env: { OPENAI_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionPrompt).toHaveBeenCalledWith( expect.objectContaining({ format: { type: 'json_schema', schema: { type: 'object', properties: { answer: { type: 'string' }, }, required: ['answer'], }, retryCount: 2, }, variant: 'fast', }), ); }); it('should inject apiKey into server config for the selected provider', async () => { const provider = new OpenCodeSDKProvider({ config: { apiKey: 'test-api-key', provider_id: 'openai', }, }); await provider.callApi('Test prompt'); expect(mockCreateOpencode).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ provider: { openai: { options: { apiKey: 'test-api-key', }, }, }, }), }), ); }); it('should pass env overrides to the spawned server process', async () => { const provider = new OpenCodeSDKProvider({ env: { OPENAI_API_KEY: 'override-openai', ANTHROPIC_API_KEY: 'override-anthropic', }, }); await provider.callApi('Test prompt'); expect(mockCreateOpencode).toHaveBeenCalledWith( expect.objectContaining({ env: expect.objectContaining({ OPENAI_API_KEY: 'override-openai', ANTHROPIC_API_KEY: 'override-anthropic', }), }), ); }); }); describe('custom agent new properties', () => { it('should pass top_p, steps, color, disable, hidden to server config', async () => { const provider = new OpenCodeSDKProvider({ config: { custom_agent: { description: 'Test agent', model: 'test-model', temperature: 0.5, top_p: 0.9, steps: 10, color: '#ff5500', disable: false, hidden: true, mode: 'subagent', }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); // Verify createOpencode was called with the custom agent config expect(mockCreateOpencode).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ agent: { custom: expect.objectContaining({ description: 'Test agent', model: 'test-model', temperature: 0.5, top_p: 0.9, maxSteps: 10, // steps maps to maxSteps color: '#ff5500', disable: false, hidden: true, mode: 'subagent', }), }, }), }), ); }); it('should support deprecated maxSteps as fallback', async () => { const provider = new OpenCodeSDKProvider({ config: { custom_agent: { description: 'Test agent', maxSteps: 5, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockCreateOpencode).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ agent: { custom: expect.objectContaining({ maxSteps: 5, }), }, }), }), ); }); it('should prefer steps over maxSteps when both provided', async () => { const provider = new OpenCodeSDKProvider({ config: { custom_agent: { description: 'Test agent', steps: 10, maxSteps: 5, // Should be ignored }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockCreateOpencode).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ agent: { custom: expect.objectContaining({ maxSteps: 10, // steps takes precedence }), }, }), }), ); }); }); describe('MCP OAuth configuration', () => { it('should support OAuth config for remote MCP servers', async () => { const provider = new OpenCodeSDKProvider({ config: { mcp: { 'oauth-server': { type: 'remote', url: 'https://secure.example.com/mcp', oauth: { clientId: 'test-client-id', clientSecret: 'test-secret', scope: 'read write', }, }, }, }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockCreateOpencode).toHaveBeenCalledWith( expect.objectContaining({ config: expect.objectContaining({ mcp: { 'oauth-server': { type: 'remote', url: 'https://secure.example.com/mcp', oauth: { clientId: 'test-client-id', clientSecret: 'test-secret', scope: 'read write', }, }, }, }), }), ); }); }); describe('parent_session_id (forked sessions)', () => { it('forwards parent_session_id as parentID on session.create for v2', async () => { const provider = new OpenCodeSDKProvider({ config: { parent_session_id: 'parent-session-abc' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionCreate).toHaveBeenCalledWith( expect.objectContaining({ parentID: 'parent-session-abc' }), ); }); it('does not include parentID when parent_session_id is unset', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); const createArgs = mockSessionCreate.mock.calls[0]?.[0]; expect(createArgs).not.toHaveProperty('parentID'); }); it('uses a separate cached session per parent_session_id', async () => { const baseConfig = { persist_sessions: true }; const providerForParentA = new OpenCodeSDKProvider({ config: { ...baseConfig, parent_session_id: 'parent-A' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const providerForParentB = new OpenCodeSDKProvider({ config: { ...baseConfig, parent_session_id: 'parent-B' }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); // Distinct sessions for two distinct parent IDs mockSessionCreate .mockResolvedValueOnce(createMockSessionResponse('child-of-A')) .mockResolvedValueOnce(createMockSessionResponse('child-of-B')); await providerForParentA.callApi('Hello A'); await providerForParentB.callApi('Hello B'); expect(mockSessionCreate).toHaveBeenCalledTimes(2); expect(mockSessionCreate.mock.calls[0]?.[0]).toMatchObject({ parentID: 'parent-A' }); expect(mockSessionCreate.mock.calls[1]?.[0]).toMatchObject({ parentID: 'parent-B' }); }); }); describe('enable_streaming dead-config warning', () => { it('warns once when enable_streaming is set, then stays quiet on subsequent calls', async () => { const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); const provider = new OpenCodeSDKProvider({ config: { enable_streaming: true, persist_sessions: true }, env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('first'); await provider.callApi('second'); const streamingWarnings = warnSpy.mock.calls.filter((call) => String(call[0] ?? '').includes('enable_streaming is currently a no-op'), ); expect(streamingWarnings).toHaveLength(1); }); it('does not warn when enable_streaming is unset', async () => { const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); const streamingWarnings = warnSpy.mock.calls.filter((call) => String(call[0] ?? '').includes('enable_streaming'), ); expect(streamingWarnings).toHaveLength(0); }); }); describe('abortSignal mid-flight cancellation', () => { it('calls session.abort when the caller aborts during the prompt', async () => { const controller = new AbortController(); const promptStarted = createDeferred(); // The prompt mock waits for the abort signal itself, then returns a // canned response — that mirrors how a real server completes after an // abort request rather than hanging forever. mockSessionPrompt.mockImplementationOnce( () => new Promise((resolve) => { promptStarted.resolve(); controller.signal.addEventListener( 'abort', () => resolve( createMockPromptResponse([{ type: 'text', text: 'Discarded' }], { input: 1, output: 1, }), ), { once: true }, ); }), ); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const callPromise = provider.callApi('Test prompt', undefined, { abortSignal: controller.signal, }); await promptStarted.promise; controller.abort(); const result = await callPromise; expect(mockSessionAbort).toHaveBeenCalledWith( expect.objectContaining({ sessionID: 'test-session-123' }), ); expect(result.error).toBe('OpenCode SDK call aborted'); }); it('does not call session.abort when no signal is provided', async () => { const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt'); expect(mockSessionAbort).not.toHaveBeenCalled(); }); it('does not call session.abort when the signal never fires', async () => { const controller = new AbortController(); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); await provider.callApi('Test prompt', undefined, { abortSignal: controller.signal, }); expect(mockSessionAbort).not.toHaveBeenCalled(); }); it('returns the abort-before-start error when the signal is already aborted', async () => { const controller = new AbortController(); controller.abort(); const provider = new OpenCodeSDKProvider({ env: { ANTHROPIC_API_KEY: 'test-api-key' }, }); const result = await provider.callApi('Test prompt', undefined, { abortSignal: controller.signal, }); expect(result.error).toBe('OpenCode SDK call aborted before it started'); expect(mockSessionPrompt).not.toHaveBeenCalled(); expect(mockSessionAbort).not.toHaveBeenCalled(); }); }); });