Files
wehub-resource-sync 0d3cb498a3
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

1147 lines
38 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import cliState from '../../../src/cliState';
import {
ATTACKER_MODEL,
ATTACKER_MODEL_SMALL,
TEMPERATURE,
} from '../../../src/redteam/providers/constants';
import {
BLOCKING_QUESTION_ANALYSIS_FEATURE_FLAG_TIMESTAMP,
buildGraderResultAssertion,
createIterationContext,
formatRedteamHistoryAsTranscript,
getGraderAssertionValue,
getTargetResponse,
type Message,
messagesToRedteamHistory,
redteamProviderManager,
resetRedteamProviderLoader,
setRedteamProviderLoader,
tryUnblocking,
} from '../../../src/redteam/providers/shared';
import { sleep } from '../../../src/util/time';
import { createMockProvider } from '../../factories/provider';
import { mockProcessEnv } from '../../util/utils';
import type {
ApiProvider,
Assertion,
AssertionSet,
CallApiContextParams,
CallApiOptionsParams,
Prompt,
} from '../../../src/types/index';
// Hoisted mocks for class constructor and loadApiProviders
const mockLoadApiProviders = vi.hoisted(() => vi.fn());
const mockCheckServerFeatureSupport = vi.hoisted(() => vi.fn());
// Create a hoisted mock class that can be instantiated with `new`
const mockOpenAiInstances: any[] = [];
const MockOpenAiChatCompletionProvider = vi.hoisted(() => {
return class MockOpenAiChatCompletionProvider {
id: () => string;
callApi: any;
toString: () => string;
config: any;
getApiKey: any;
getApiUrl: any;
getApiUrlDefault: any;
getOrganization: any;
requiresApiKey: any;
initializationPromise: null;
loadedFunctionCallbacks: {};
mcpClient: null;
constructor(model: string, options?: any) {
this.id = () => `openai:${model}`;
this.callApi = vi.fn();
this.toString = () => `OpenAI(${model})`;
this.config = options?.config || {};
this.getApiKey = vi.fn();
this.getApiUrl = vi.fn();
this.getApiUrlDefault = vi.fn();
this.getOrganization = vi.fn();
this.requiresApiKey = vi.fn();
this.initializationPromise = null;
this.loadedFunctionCallbacks = {};
this.mcpClient = null;
mockOpenAiInstances.push(this);
}
static mock: ReturnType<typeof vi.fn> = vi.fn();
};
});
vi.mock('../../../src/util/time');
vi.mock('../../../src/cliState', () => ({
__esModule: true,
default: {
config: {
redteam: {
provider: undefined,
},
},
},
}));
vi.mock('../../../src/providers/openai/chat', () => ({
OpenAiChatCompletionProvider: MockOpenAiChatCompletionProvider,
}));
vi.mock('../../../src/providers/index', () => ({
loadApiProviders: mockLoadApiProviders,
}));
vi.mock('../../../src/util/server', () => ({
checkServerFeatureSupport: mockCheckServerFeatureSupport,
}));
const mockedSleep = vi.mocked(sleep);
const mockedLoadApiProviders = mockLoadApiProviders;
const mockedCheckServerFeatureSupport = mockCheckServerFeatureSupport;
function setCliStateConfig(config: typeof cliState.config) {
cliState.config = config;
}
describe('shared redteam provider utilities', () => {
afterEach(() => {
resetRedteamProviderLoader();
vi.resetAllMocks();
});
beforeEach(() => {
// Clear all mocks thoroughly
vi.clearAllMocks();
// Reset specific mocks
mockedSleep.mockReset();
mockedLoadApiProviders.mockReset();
mockedCheckServerFeatureSupport.mockReset();
// Clear the instances array
mockOpenAiInstances.length = 0;
// Clear the redteam provider manager cache
redteamProviderManager.clearProvider();
resetRedteamProviderLoader();
// Reset cliState to default
setCliStateConfig({
redteam: {
provider: undefined,
},
});
});
describe('RedteamProviderManager', () => {
const mockApiProvider = createMockProvider({ response: { output: 'test output' } });
it('creates default OpenAI provider when no provider specified', async () => {
const result = await redteamProviderManager.getProvider({});
// Check that an instance was created with the correct parameters
expect(mockOpenAiInstances.length).toBe(1);
expect(result.id()).toBe(`openai:${ATTACKER_MODEL}`);
expect(mockOpenAiInstances[0].config).toEqual({
temperature: TEMPERATURE,
response_format: undefined,
});
});
it('clears cached providers', async () => {
// First call to set up the cache
await redteamProviderManager.getProvider({});
expect(mockOpenAiInstances.length).toBe(1);
// Clear the cache and instances array
redteamProviderManager.clearProvider();
mockOpenAiInstances.length = 0;
// Second call should create a new provider
await redteamProviderManager.getProvider({});
expect(mockOpenAiInstances.length).toBe(1);
});
it('loads provider from string identifier', async () => {
mockedLoadApiProviders.mockResolvedValue([mockApiProvider]);
const result = await redteamProviderManager.getProvider({ provider: 'test-provider' });
expect(result).toBe(mockApiProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledWith(['test-provider']);
});
it('loads configured providers through an injected provider loader', async () => {
const injectedLoader = vi.fn().mockResolvedValue([mockApiProvider]);
const restoreLoader = setRedteamProviderLoader(injectedLoader);
try {
const result = await redteamProviderManager.getProvider({ provider: 'test-provider' });
expect(result).toBe(mockApiProvider);
expect(injectedLoader).toHaveBeenCalledWith(['test-provider']);
expect(mockedLoadApiProviders).not.toHaveBeenCalled();
} finally {
restoreLoader();
}
});
it('setRedteamProviderLoader returns a disposer that restores the previous loader', async () => {
const firstLoader = vi.fn().mockResolvedValue([createMockProvider({ id: 'first' })]);
const secondLoader = vi.fn().mockResolvedValue([createMockProvider({ id: 'second' })]);
const restoreFirst = setRedteamProviderLoader(firstLoader);
const restoreSecond = setRedteamProviderLoader(secondLoader);
try {
// Dispose in reverse order — second disposer restores the first loader.
restoreSecond();
redteamProviderManager.clearProvider();
await redteamProviderManager.getProvider({ provider: 'afterSecond' });
expect(firstLoader).toHaveBeenCalledWith(['afterSecond']);
expect(secondLoader).not.toHaveBeenCalled();
// Disposing the first restores the default, which dispatches through
// the file-level vi.mock of '../../../src/providers/index'.
restoreFirst();
redteamProviderManager.clearProvider();
firstLoader.mockClear();
mockedLoadApiProviders.mockResolvedValueOnce([createMockProvider({ id: 'default' })]);
await redteamProviderManager.getProvider({ provider: 'afterFirst' });
expect(firstLoader).not.toHaveBeenCalled();
expect(mockedLoadApiProviders).toHaveBeenCalledWith(['afterFirst']);
} finally {
resetRedteamProviderLoader();
}
});
it('resetRedteamProviderLoader restores the default loader path', async () => {
// Guards the production default-loader path, which is otherwise masked
// by the file-level vi.mock of '../../../src/providers/index'.
const injectedLoader = vi.fn().mockResolvedValue([mockApiProvider]);
const restoreLoader = setRedteamProviderLoader(injectedLoader);
try {
await redteamProviderManager.getProvider({ provider: 'injected-provider' });
expect(injectedLoader).toHaveBeenCalledWith(['injected-provider']);
expect(mockedLoadApiProviders).not.toHaveBeenCalled();
restoreLoader();
redteamProviderManager.clearProvider();
const secondProvider = createMockProvider({ id: 'from-default-loader' });
mockedLoadApiProviders.mockResolvedValueOnce([secondProvider]);
const result = await redteamProviderManager.getProvider({ provider: 'default-provider' });
expect(result).toBe(secondProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledWith(['default-provider']);
expect(injectedLoader).toHaveBeenCalledTimes(1);
} finally {
resetRedteamProviderLoader();
}
});
it('loads provider from provider options', async () => {
const providerOptions = { id: 'test-provider', apiKey: 'test-key' };
mockedLoadApiProviders.mockResolvedValue([mockApiProvider]);
const result = await redteamProviderManager.getProvider({ provider: providerOptions });
expect(result).toBe(mockApiProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledWith([providerOptions]);
});
it('uses small model when preferSmallModel is true', async () => {
const result = await redteamProviderManager.getProvider({ preferSmallModel: true });
// Check that an instance was created with the small model
expect(mockOpenAiInstances.length).toBe(1);
expect(result.id()).toBe(`openai:${ATTACKER_MODEL_SMALL}`);
expect(mockOpenAiInstances[0].config).toEqual({
temperature: TEMPERATURE,
response_format: undefined,
});
});
it('sets response_format to json_object when jsonOnly is true', async () => {
const result = await redteamProviderManager.getProvider({ jsonOnly: true });
// Check that an instance was created with json_object response_format
expect(mockOpenAiInstances.length).toBe(1);
expect(result.id()).toBe(`openai:${ATTACKER_MODEL}`);
expect(mockOpenAiInstances[0].config).toEqual({
temperature: TEMPERATURE,
response_format: { type: 'json_object' },
});
});
it('uses provider from cliState if available', async () => {
const mockStateProvider = createMockProvider({ id: 'state-provider' });
// Clear and set up cliState for this test
setCliStateConfig({
redteam: {
provider: mockStateProvider,
},
});
const result = await redteamProviderManager.getProvider({});
expect(result).toBe(mockStateProvider);
});
it('sets and reuses providers', async () => {
const mockProvider = createMockProvider({ response: { output: 'test output' } });
mockedLoadApiProviders.mockResolvedValue([mockProvider]);
// Set the provider
await redteamProviderManager.setProvider('test-provider');
// Get the provider - should use cached version
const result = await redteamProviderManager.getProvider({});
const jsonResult = await redteamProviderManager.getProvider({ jsonOnly: true });
expect(result).toBe(mockProvider);
expect(jsonResult).toBe(mockProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledTimes(2); // Preloads regular and jsonOnly caches
expect(mockedLoadApiProviders).toHaveBeenNthCalledWith(1, ['test-provider']);
expect(mockedLoadApiProviders).toHaveBeenNthCalledWith(2, ['test-provider']);
});
describe('getGradingProvider', () => {
it('returns cached grading provider set via setGradingProvider', async () => {
redteamProviderManager.clearProvider();
const gradingInstance = createMockProvider({ id: 'grading-cached' });
// Set concrete instance and retrieve it (jsonOnly false)
await redteamProviderManager.setGradingProvider(gradingInstance as any);
const got = await redteamProviderManager.getGradingProvider();
expect(got.id()).toBe('grading-cached');
});
it('uses defaultTest chain when no cached grading provider', async () => {
redteamProviderManager.clearProvider();
const mockProvider = createMockProvider({ id: 'from-defaultTest-provider' });
mockedLoadApiProviders.mockResolvedValue([mockProvider]);
// Inject defaultTest provider config
setCliStateConfig({
defaultTest: {
provider: 'from-defaultTest-provider',
},
});
const got = await redteamProviderManager.getGradingProvider();
expect(got).toBe(mockProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledWith(['from-defaultTest-provider']);
});
it('falls back to redteam provider when grading not set', async () => {
redteamProviderManager.clearProvider();
setCliStateConfig({}); // no defaultTest
// Expect fallback to default OpenAI redteam provider
const got = await redteamProviderManager.getGradingProvider({ jsonOnly: true });
expect(got.id()).toContain('openai:');
});
});
describe('getProvider with defaultTest fallback', () => {
afterEach(() => {
vi.resetAllMocks();
});
it('uses defaultTest.options.provider when no redteam.provider is set', async () => {
redteamProviderManager.clearProvider();
const mockProvider = createMockProvider({ id: 'defaultTest-provider' });
mockedLoadApiProviders.mockResolvedValue([mockProvider]);
// Set defaultTest.options.provider but not redteam.provider
setCliStateConfig({
redteam: {
provider: undefined,
},
defaultTest: {
options: {
provider: 'defaultTest-provider',
},
},
});
const got = await redteamProviderManager.getProvider({});
expect(got).toBe(mockProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledWith(['defaultTest-provider']);
});
it('uses defaultTest.provider when no redteam.provider is set', async () => {
redteamProviderManager.clearProvider();
const mockProvider = createMockProvider({ id: 'defaultTest-direct-provider' });
mockedLoadApiProviders.mockResolvedValue([mockProvider]);
// Set defaultTest.provider directly
setCliStateConfig({
redteam: {
provider: undefined,
},
defaultTest: {
provider: 'defaultTest-direct-provider',
},
});
const got = await redteamProviderManager.getProvider({});
expect(got).toBe(mockProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledWith(['defaultTest-direct-provider']);
});
it('prefers redteam.provider over defaultTest provider', async () => {
redteamProviderManager.clearProvider();
const redteamProvider = createMockProvider({ id: 'redteam-explicit-provider' });
mockedLoadApiProviders.mockResolvedValue([redteamProvider]);
// Set both redteam.provider and defaultTest.options.provider
setCliStateConfig({
redteam: {
provider: 'redteam-explicit-provider',
},
defaultTest: {
options: {
provider: 'defaultTest-provider',
},
},
});
const got = await redteamProviderManager.getProvider({});
expect(got).toBe(redteamProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledWith(['redteam-explicit-provider']);
});
it('falls back to OpenAI default when neither redteam.provider nor defaultTest provider is set', async () => {
redteamProviderManager.clearProvider();
mockOpenAiInstances.length = 0;
setCliStateConfig({
redteam: {
provider: undefined,
},
// No defaultTest
});
const got = await redteamProviderManager.getProvider({});
expect(got.id()).toContain('openai:');
expect(mockOpenAiInstances.length).toBe(1);
});
});
it('handles thrown errors in getTargetResponse', async () => {
const mockProvider = createMockProvider({
callApi: vi.fn<ApiProvider['callApi']>().mockRejectedValue(new Error('Network error')),
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: '',
error: 'Network error',
tokenUsage: { numRequests: 1 },
});
});
it('re-throws AbortError from getTargetResponse and does not swallow it', async () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
const mockProvider = createMockProvider({
callApi: vi.fn<ApiProvider['callApi']>().mockRejectedValue(abortError),
});
await expect(getTargetResponse(mockProvider, 'test prompt')).rejects.toThrow(
'The operation was aborted',
);
});
it('swallows non-AbortError exceptions and returns error response', async () => {
const regularError = new Error('API timeout');
const mockProvider = createMockProvider({
callApi: vi.fn<ApiProvider['callApi']>().mockRejectedValue(regularError),
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result.error).toBe('API timeout');
expect(result.output).toBe('');
});
describe('Multilingual provider', () => {
it('getMultilingualProvider returns undefined when not set', async () => {
// Ensure clean state
redteamProviderManager.clearProvider();
const result = await redteamProviderManager.getMultilingualProvider();
expect(result).toBeUndefined();
});
it('setMultilingualProvider caches provider and getMultilingualProvider returns it', async () => {
const mockProvider = createMockProvider({ id: 'test-multilingual-provider' });
mockedLoadApiProviders.mockResolvedValueOnce([mockProvider]);
await redteamProviderManager.setMultilingualProvider('test-multilingual-provider');
const result = await redteamProviderManager.getMultilingualProvider();
expect(result).toBe(mockProvider);
expect(mockedLoadApiProviders).toHaveBeenCalledWith(['test-multilingual-provider']);
});
it('setMultilingualProvider uses jsonOnly response_format when defaulting to OpenAI', async () => {
// Pass undefined to trigger default provider creation
await redteamProviderManager.setMultilingualProvider(undefined as any);
// Check that an instance was created with json_object response_format
expect(mockOpenAiInstances.length).toBe(1);
expect(mockOpenAiInstances[0].config).toEqual({
temperature: TEMPERATURE,
response_format: { type: 'json_object' },
});
});
});
});
describe('getTargetResponse', () => {
it('returns an error before calling the target when the prompt exceeds maxCharsPerMessage', async () => {
setCliStateConfig({
redteam: {
maxCharsPerMessage: 5,
},
});
const mockProvider = createMockProvider({
response: {
output: 'test response',
tokenUsage: { numRequests: 1 },
},
});
const result = await getTargetResponse(mockProvider, 'too long');
expect(mockProvider.callApi).not.toHaveBeenCalled();
expect(result).toEqual({
output: '',
error: 'Target prompt message at prompt exceeds maxCharsPerMessage=5: 8 characters.',
tokenUsage: { numRequests: 0 },
});
});
it('only enforces maxCharsPerMessage for user messages in chat arrays', async () => {
setCliStateConfig({
redteam: {
maxCharsPerMessage: 5,
},
});
const mockProvider = createMockProvider({
response: {
output: 'ok',
tokenUsage: { numRequests: 1 },
},
});
const prompt = JSON.stringify([
{ role: 'system', content: 'this system message is long' },
{ role: 'user', content: 'short' },
]);
const result = await getTargetResponse(mockProvider, prompt);
expect(mockProvider.callApi).toHaveBeenCalledWith(prompt, undefined, undefined);
expect(result.output).toBe('ok');
});
it('uses maxCharsPerMessage from test metadata when cliState is unset', async () => {
const mockProvider = createMockProvider({
response: {
output: 'test response',
tokenUsage: { numRequests: 1 },
},
});
const context = {
prompt: { raw: '', label: '' },
vars: {},
test: {
metadata: {
pluginConfig: {
maxCharsPerMessage: 5,
},
},
},
} as CallApiContextParams;
const result = await getTargetResponse(mockProvider, 'too long', context);
expect(mockProvider.callApi).not.toHaveBeenCalled();
expect(result.error).toBe(
'Target prompt message at prompt exceeds maxCharsPerMessage=5: 8 characters.',
);
});
it('checks GOAT audio/image hybrid payload transcript text without counting base64 blobs', async () => {
const mockProvider = createMockProvider({
response: {
output: 'ok',
tokenUsage: { numRequests: 1 },
},
});
const context = {
prompt: { raw: '', label: '' },
vars: {},
test: {
metadata: {
pluginConfig: {
maxCharsPerMessage: 5,
},
},
},
} as CallApiContextParams;
const prompt = JSON.stringify({
_promptfoo_audio_hybrid: true,
history: [
{ role: 'user', content: 'tiny' },
{ role: 'assistant', content: 'this assistant response is long and should be ignored' },
],
currentTurn: {
role: 'user',
transcript: 'short',
image: {
data: 'a'.repeat(500),
format: 'png',
},
},
});
const result = await getTargetResponse(mockProvider, prompt, context);
expect(mockProvider.callApi).toHaveBeenCalledWith(prompt, context, undefined);
expect(result.output).toBe('ok');
});
it('rejects GOAT audio/image hybrid payloads when currentTurn transcript exceeds maxCharsPerMessage', async () => {
const mockProvider = createMockProvider({
response: {
output: 'ok',
tokenUsage: { numRequests: 1 },
},
});
const context = {
prompt: { raw: '', label: '' },
vars: {},
test: {
metadata: {
pluginConfig: {
maxCharsPerMessage: 5,
},
},
},
} as CallApiContextParams;
const prompt = JSON.stringify({
_promptfoo_audio_hybrid: true,
history: [],
currentTurn: {
role: 'user',
transcript: 'too long',
audio: {
data: 'a'.repeat(500),
format: 'mp3',
},
},
});
const result = await getTargetResponse(mockProvider, prompt, context);
expect(mockProvider.callApi).not.toHaveBeenCalled();
expect(result.error).toBe(
'Target prompt message at currentTurn.transcript exceeds maxCharsPerMessage=5: 8 characters.',
);
});
it('returns successful response with string output', async () => {
const mockProvider = createMockProvider({
response: {
output: 'test response',
tokenUsage: { total: 10, prompt: 5, completion: 5, numRequests: 1 },
sessionId: 'test-session',
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: 'test response',
tokenUsage: { total: 10, prompt: 5, completion: 5, numRequests: 1 },
sessionId: 'test-session',
});
});
it('passes through context and options', async () => {
const mockCallApi = vi.fn().mockResolvedValue({
output: 'test response',
tokenUsage: { numRequests: 1 },
});
const mockProvider = createMockProvider({ callApi: mockCallApi });
const prompt: Prompt = {
raw: 'test prompt',
label: 'test',
};
const context: CallApiContextParams = {
prompt,
vars: { test: 'value' },
};
const options: CallApiOptionsParams = {};
await getTargetResponse(mockProvider, 'test prompt', context, options);
expect(mockCallApi).toHaveBeenCalledWith('test prompt', context, options);
});
it('stringifies non-string output', async () => {
const mockProvider = createMockProvider({
response: {
output: { key: 'value' },
tokenUsage: { numRequests: 1 },
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: '{"key":"value"}',
tokenUsage: { numRequests: 1 },
});
});
it('handles provider error response', async () => {
const mockProvider = createMockProvider({
response: {
error: 'API error',
sessionId: 'error-session',
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: '',
error: 'API error',
sessionId: 'error-session',
tokenUsage: { numRequests: 1 },
});
});
it('respects provider delay for non-cached responses', async () => {
const mockProvider = createMockProvider({
delay: 100,
response: {
output: 'test response',
tokenUsage: { numRequests: 1 },
},
});
await getTargetResponse(mockProvider, 'test prompt');
expect(mockedSleep).toHaveBeenCalledWith(100);
});
it('skips delay for cached responses', async () => {
const mockProvider = createMockProvider({
delay: 100,
response: {
output: 'test response',
cached: true,
tokenUsage: { numRequests: 1 },
},
});
await getTargetResponse(mockProvider, 'test prompt');
expect(mockedSleep).not.toHaveBeenCalled();
});
it('throws error when neither output nor error is set', async () => {
const mockProvider = createMockProvider({ response: {} });
await expect(getTargetResponse(mockProvider, 'test prompt')).rejects.toThrow(
/Target returned malformed response: expected either `output` or `error` property to be set/,
);
});
it('uses default tokenUsage when not provided', async () => {
const mockProvider = createMockProvider({
response: {
output: 'test response',
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: 'test response',
tokenUsage: { numRequests: 1 },
});
});
it('accepts conversationEnded without output or error', async () => {
const mockProvider = createMockProvider({
response: {
conversationEnded: true,
conversationEndReason: 'thread_closed',
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: '',
conversationEnded: true,
conversationEndReason: 'thread_closed',
tokenUsage: { numRequests: 1 },
});
});
describe('edge cases for empty and falsy responses', () => {
it('handles empty string output correctly', async () => {
const mockProvider = createMockProvider({
response: {
output: '', // Empty string
tokenUsage: { numRequests: 1 },
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: '',
tokenUsage: { numRequests: 1 },
});
});
it('handles zero output correctly', async () => {
const mockProvider = createMockProvider({
response: {
output: 0, // Zero value
tokenUsage: { numRequests: 1 },
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: '0', // Should be stringified
tokenUsage: { numRequests: 1 },
});
});
it('handles false output correctly', async () => {
const mockProvider = createMockProvider({
response: {
output: false, // Boolean false
tokenUsage: { numRequests: 1 },
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: 'false', // Should be stringified
tokenUsage: { numRequests: 1 },
});
});
it('handles null output correctly', async () => {
const mockProvider = createMockProvider({
response: {
output: null, // Null value
tokenUsage: { numRequests: 1 },
},
});
const result = await getTargetResponse(mockProvider, 'test prompt');
expect(result).toEqual({
output: 'null', // Should be stringified
tokenUsage: { numRequests: 1 },
});
});
it('still fails when output property is missing', async () => {
const mockProvider = createMockProvider({
response: {
// No output property at all
tokenUsage: { numRequests: 1 },
},
});
await expect(getTargetResponse(mockProvider, 'test prompt')).rejects.toThrow(
/Target returned malformed response: expected either `output` or `error` property to be set/,
);
});
it('still fails when both output and error are missing', async () => {
const mockProvider = createMockProvider({
response: {
someOtherField: 'value',
} as any,
});
await expect(getTargetResponse(mockProvider, 'test prompt')).rejects.toThrow(
/Target returned malformed response/,
);
});
});
});
describe('messagesToRedteamHistory', () => {
it('converts valid messages to redteamHistory format', () => {
const messages: Message[] = [
{ role: 'system', content: 'system message' },
{ role: 'user', content: 'user message 1' },
{ role: 'assistant', content: 'assistant response 1' },
{ role: 'user', content: 'user message 2' },
{ role: 'assistant', content: 'assistant response 2' },
];
const result = messagesToRedteamHistory(messages);
expect(result).toEqual([
{ prompt: 'user message 1', output: 'assistant response 1' },
{ prompt: 'user message 2', output: 'assistant response 2' },
]);
});
it('handles empty messages array', () => {
const messages: Message[] = [];
const result = messagesToRedteamHistory(messages);
expect(result).toEqual([]);
});
it('handles messages with missing content', () => {
const messages: Message[] = [
{ role: 'user', content: '' },
{ role: 'assistant', content: undefined as any },
{ role: 'user', content: 'valid message' },
{ role: 'assistant', content: 'valid response' },
];
const result = messagesToRedteamHistory(messages);
expect(result).toEqual([
{ prompt: '', output: '' },
{ prompt: 'valid message', output: 'valid response' },
]);
});
it('handles malformed messages gracefully', () => {
const messages = [
{ wrong: 'format' },
null,
undefined,
{ role: 'user', content: 'valid message' },
{ role: 'assistant', content: 'valid response' },
] as Message[];
const result = messagesToRedteamHistory(messages);
expect(result).toEqual([{ prompt: 'valid message', output: 'valid response' }]);
});
it('handles non-array input gracefully', () => {
const result = messagesToRedteamHistory(null as any);
expect(result).toEqual([]);
});
it('skips incomplete message pairs', () => {
const messages: Message[] = [
{ role: 'user', content: 'user message 1' },
{ role: 'user', content: 'user message 2' },
{ role: 'assistant', content: 'assistant response 2' },
];
const result = messagesToRedteamHistory(messages);
expect(result).toEqual([{ prompt: 'user message 2', output: 'assistant response 2' }]);
});
});
describe('formatRedteamHistoryAsTranscript', () => {
it('formats turn history as a readable transcript', () => {
const result = formatRedteamHistoryAsTranscript([
{ prompt: 'first question', output: 'first response' },
{ prompt: 'second question', output: 'second response' },
]);
expect(result).toBe(
'Turn 1:\nUser: first question\nAssistant: first response\n\nTurn 2:\nUser: second question\nAssistant: second response',
);
});
it('returns an empty string for empty history', () => {
expect(formatRedteamHistoryAsTranscript([])).toBe('');
});
});
// New tests for tryUnblocking env flag
describe('tryUnblocking environment flag', () => {
const originalEnv = process.env.PROMPTFOO_ENABLE_UNBLOCKING;
afterEach(() => {
if (originalEnv === undefined) {
mockProcessEnv({ PROMPTFOO_ENABLE_UNBLOCKING: undefined });
} else {
mockProcessEnv({ PROMPTFOO_ENABLE_UNBLOCKING: originalEnv });
}
});
it('short-circuits by default when PROMPTFOO_ENABLE_UNBLOCKING is not set', async () => {
mockProcessEnv({ PROMPTFOO_ENABLE_UNBLOCKING: undefined });
const result = await tryUnblocking({
messages: [],
lastResponse: 'irrelevant',
goal: 'test-goal',
purpose: 'test-purpose',
});
expect(result.success).toBe(false);
expect(result.unblockingPrompt).toBeUndefined();
expect(mockedCheckServerFeatureSupport).not.toHaveBeenCalled();
});
it('checks server support when PROMPTFOO_ENABLE_UNBLOCKING=true', async () => {
mockProcessEnv({ PROMPTFOO_ENABLE_UNBLOCKING: 'true' });
mockedCheckServerFeatureSupport.mockResolvedValue(false);
const result = await tryUnblocking({
messages: [],
lastResponse: 'What industry are you in?',
goal: 'test-goal',
purpose: 'test-purpose',
});
expect(result.success).toBe(false);
expect(mockedCheckServerFeatureSupport).toHaveBeenCalledWith(
'blocking-question-analysis',
BLOCKING_QUESTION_ANALYSIS_FEATURE_FLAG_TIMESTAMP,
);
});
});
describe('grader assertion helpers', () => {
const singleAssertion: Assertion = {
type: 'llm-rubric',
value: 'original rubric',
};
const assertionSet: AssertionSet = {
type: 'assert-set',
assert: [singleAssertion],
};
it('uses grade assertion when present', () => {
expect(
buildGraderResultAssertion(
{ type: 'javascript', pass: true, score: 1, reason: 'ok' } as Assertion,
singleAssertion,
'rendered rubric',
),
).toEqual({
type: 'javascript',
pass: true,
score: 1,
reason: 'ok',
value: 'rendered rubric',
});
});
it('falls back to a single assertion and exposes its value', () => {
expect(buildGraderResultAssertion(undefined, singleAssertion, 'rendered rubric')).toEqual({
type: 'llm-rubric',
value: 'rendered rubric',
});
expect(getGraderAssertionValue(singleAssertion)).toBe('original rubric');
});
it('ignores assert-set assertions and undefined values', () => {
expect(
buildGraderResultAssertion(undefined, assertionSet, 'rendered rubric'),
).toBeUndefined();
expect(getGraderAssertionValue(assertionSet)).toBeUndefined();
expect(getGraderAssertionValue(undefined)).toBeUndefined();
});
});
describe('createIterationContext', () => {
it('returns undefined when no outer context is passed', async () => {
const transformSpy = vi.fn((vars) => ({
...(vars as Record<string, unknown>),
goal: `${(vars as Record<string, unknown>).goal} (iter-1)`,
}));
const iterationContext = await createIterationContext({
originalVars: { goal: 'test' },
transformVarsConfig: transformSpy,
iterationNumber: 1,
});
// Without an outer context there's nothing to wrap — the transform still
// runs (verifies the happy path isn't short-circuited) but the function
// has no CallApiContextParams to return.
expect(iterationContext).toBeUndefined();
expect(transformSpy).toHaveBeenCalledTimes(1);
expect(transformSpy).toHaveBeenCalledWith(
{ goal: 'test' },
expect.objectContaining({ prompt: {}, uuid: expect.any(String) }),
);
});
it('merges transformed vars into the iteration context', async () => {
const outer: CallApiContextParams = {
prompt: { raw: 'x', label: 'x' },
vars: {},
};
const iterationContext = await createIterationContext({
originalVars: { goal: 'test' },
transformVarsConfig: (vars) => ({
...(vars as Record<string, unknown>),
goal: `${(vars as Record<string, unknown>).goal}!!`,
}),
context: outer,
iterationNumber: 2,
});
expect(iterationContext?.vars).toEqual({ goal: 'test!!' });
});
it('rethrows errors from a TransformFunction so iterations surface user bugs', async () => {
const throwingTransform = () => {
throw new Error('user function boom');
};
await expect(
createIterationContext({
originalVars: { goal: 'test' },
transformVarsConfig: throwingTransform,
iterationNumber: 1,
}),
).rejects.toThrow('user function boom');
});
it('keeps legacy best-effort behavior for inline string transforms', async () => {
const outer: CallApiContextParams = {
prompt: { raw: 'x', label: 'x' },
vars: {},
};
// This inline expression throws because `vars.missing` is undefined.
// The legacy behavior is to log and continue with the original vars.
const iterationContext = await createIterationContext({
originalVars: { goal: 'test' },
transformVarsConfig: 'vars.missing.field',
context: outer,
iterationNumber: 1,
});
expect(iterationContext?.vars).toEqual({ goal: 'test' });
});
});
});