0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
1838 lines
57 KiB
TypeScript
1838 lines
57 KiB
TypeScript
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<string, unknown>;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
}>,
|
|
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<void>();
|
|
// 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();
|
|
});
|
|
});
|
|
});
|