Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

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();
});
});
});