Files
promptfoo--promptfoo/test/redteam/providers/multi-turn-empty-response.test.ts
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

310 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CrescendoProvider } from '../../../src/redteam/providers/crescendo/index';
import { CustomProvider } from '../../../src/redteam/providers/custom/index';
import RedteamIterativeProvider from '../../../src/redteam/providers/iterative';
import { createMockProvider } from '../../factories/provider';
import { mockProcessEnv } from '../../util/utils';
import type { ApiProvider, AtomicTestCase, CallApiContextParams } from '../../../src/types/index';
// Use vi.hoisted for proper mock isolation
const mockGetProvider = vi.hoisted(() => vi.fn());
const mockGetTargetResponse = vi.hoisted(() => vi.fn());
vi.mock('../../../src/globalConfig/accounts', async (importOriginal) => ({
...(await importOriginal()),
isLoggedIntoCloud: vi.fn().mockReturnValue(true),
}));
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
getLogLevel: vi.fn().mockReturnValue('info'),
}));
// Mock the shared module with hoisted functions
vi.mock('../../../src/redteam/providers/shared', async (importOriginal) => {
return {
...(await importOriginal()),
getTargetResponse: mockGetTargetResponse,
redteamProviderManager: {
getProvider: mockGetProvider,
getGradingProvider: mockGetProvider,
},
// Mock tryUnblocking to avoid network calls
tryUnblocking: vi.fn().mockResolvedValue({ success: false }),
};
});
// Mock graders to avoid dynamic import issues
vi.mock('../../../src/redteam/graders', async (importOriginal) => {
return {
...(await importOriginal()),
getGraderById: vi.fn().mockReturnValue(undefined),
};
});
// Mock remote generation to ensure consistent behavior
vi.mock('../../../src/redteam/remoteGeneration', async (importOriginal) => {
return {
...(await importOriginal()),
shouldGenerateRemote: vi.fn().mockReturnValue(false),
neverGenerateRemote: vi.fn().mockReturnValue(false),
};
});
describe('Multi-turn strategies empty response handling', () => {
let restoreEnv: () => void;
const createMockTargetProvider = () => createMockProvider({ id: 'mock-target' });
const createTestContext = (targetProvider: ApiProvider): CallApiContextParams => ({
originalProvider: targetProvider,
vars: { prompt: 'test value' },
prompt: { raw: 'Test prompt: {{prompt}}', label: 'test' },
test: {
metadata: { goal: 'Test goal for empty response handling' },
assert: [],
} as AtomicTestCase,
});
// Create a mock redteam provider that returns appropriate responses
const createMockRedteamProvider = () =>
createMockProvider({
id: 'mock-redteam-provider',
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation(async (_prompt, context) => {
// Prefer prompt labels when available: they are explicit and avoid false matches
// against attack-generation system prompts that may also mention "conversation objective".
const label = context?.prompt?.label;
if (label === 'refusal') {
return {
output: JSON.stringify({
value: false, // not a refusal
metadata: 0,
rationale: 'Mock: not a refusal',
}),
};
}
if (label === 'eval') {
return {
output: JSON.stringify({
value: false,
metadata: 0,
rationale: 'Mock: objective not met',
description: 'Mock description',
}),
};
}
// Default: attack prompt generation / history prompts.
return {
output: JSON.stringify({
generatedQuestion: 'mocked question',
rationaleBehindJailbreak: 'mocked rationale',
lastResponseSummary: 'mocked summary',
}),
};
}),
delay: 0,
});
beforeEach(() => {
vi.clearAllMocks();
// Reset hoisted mocks
mockGetProvider.mockReset();
mockGetTargetResponse.mockReset();
restoreEnv = mockProcessEnv({ OPENAI_API_KEY: 'test-api-key' });
// Set up the mock provider
mockGetProvider.mockResolvedValue(createMockRedteamProvider());
});
afterEach(() => {
restoreEnv();
});
describe('Crescendo strategy', () => {
it('handles empty string responses without throwing invariant error', async () => {
// Mock getTargetResponse to return empty string
mockGetTargetResponse.mockResolvedValue({
output: '',
tokenUsage: { numRequests: 1 },
});
const mockTarget = createMockTargetProvider();
const strategy = new CrescendoProvider({
injectVar: 'prompt',
redteamProvider: 'openai:gpt-4',
maxTurns: 1,
maxBacktracks: 0,
});
const context = createTestContext(mockTarget);
// This should not throw the invariant error that was happening before
const result = await strategy.callApi('test prompt', context);
expect(result).toBeDefined();
expect(result.output).toBeDefined();
expect(result.metadata).toBeDefined();
expect(result.tokenUsage).toBeDefined();
});
it('handles other falsy values without throwing invariant error', async () => {
const falsyValues = [0, false, null];
for (const value of falsyValues) {
// Mock getTargetResponse to return falsy value
mockGetTargetResponse.mockResolvedValue({
output: value as any,
tokenUsage: { numRequests: 1 },
});
const mockTarget = createMockTargetProvider();
const strategy = new CrescendoProvider({
injectVar: 'prompt',
redteamProvider: 'openai:gpt-4',
maxTurns: 1,
maxBacktracks: 0,
});
const context = createTestContext(mockTarget);
// Should not throw invariant error for any falsy but valid output
const result = await strategy.callApi('test prompt', context);
expect(result).toBeDefined();
expect(result.output).toBeDefined();
expect(result.metadata).toBeDefined();
expect(result.tokenUsage).toBeDefined();
}
});
it('stops early when target ends conversation', async () => {
mockGetTargetResponse.mockResolvedValue({
output: '',
conversationEnded: true,
conversationEndReason: 'thread_closed',
tokenUsage: { numRequests: 1 },
});
const mockTarget = createMockTargetProvider();
const strategy = new CrescendoProvider({
injectVar: 'prompt',
redteamProvider: 'openai:gpt-4',
maxTurns: 3,
maxBacktracks: 0,
});
const context = createTestContext(mockTarget);
const result = await strategy.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Target ended conversation');
expect(result.metadata?.crescendoRoundsCompleted).toBe(1);
});
});
describe('Custom strategy', () => {
it('handles empty string responses without throwing invariant error', async () => {
// Mock getTargetResponse to return empty string
mockGetTargetResponse.mockResolvedValue({
output: '',
tokenUsage: { numRequests: 1 },
});
const mockTarget = createMockTargetProvider();
const strategy = new CustomProvider({
injectVar: 'prompt',
redteamProvider: 'openai:gpt-4',
maxTurns: 1,
strategyText: 'Test strategy for empty responses',
});
const context = createTestContext(mockTarget);
// This should not throw the invariant error that was happening before
const result = await strategy.callApi('test prompt', context);
expect(result).toBeDefined();
expect(result.output).toBeDefined();
expect(result.metadata).toBeDefined();
expect(result.tokenUsage).toBeDefined();
});
it('stops early when target ends conversation', async () => {
mockGetTargetResponse.mockResolvedValue({
output: '',
conversationEnded: true,
conversationEndReason: 'thread_closed',
tokenUsage: { numRequests: 1 },
});
const mockTarget = createMockTargetProvider();
const strategy = new CustomProvider({
injectVar: 'prompt',
redteamProvider: 'openai:gpt-4',
maxTurns: 3,
strategyText: 'Test strategy for target-ended conversations',
});
const context = createTestContext(mockTarget);
const result = await strategy.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Target ended conversation');
expect(result.metadata?.customRoundsCompleted).toBe(1);
});
});
describe('Iterative strategy', () => {
it('processes empty string responses instead of skipping iterations', async () => {
// Mock getTargetResponse to return empty string
mockGetTargetResponse.mockResolvedValue({
output: '',
tokenUsage: { numRequests: 1 },
});
const mockTarget = createMockTargetProvider();
const strategy = new RedteamIterativeProvider({
injectVar: 'prompt',
redteamProvider: 'openai:gpt-4',
numIterations: '2',
});
const context = createTestContext(mockTarget);
// This should process the empty response instead of skipping it
const result = await strategy.callApi('test prompt', context);
expect(result).toBeDefined();
expect(result.output).toBeDefined();
expect(result.metadata).toBeDefined();
expect(result.tokenUsage).toBeDefined();
});
});
// Test that our fix doesn't break when responses are truly malformed
describe('Validation still works for malformed responses', () => {
it('demonstrates the fix works for the core issue', () => {
// This test documents that the fix is working - the key insight is that
// before our fix, empty string responses would cause invariant failures.
// The successful tests above prove that this core issue is resolved.
// The specific invariant checks we fixed:
// - Object.prototype.hasOwnProperty.call(targetResponse, 'output') instead of targetResponse.output
// - This allows empty strings, zeros, false, null to pass validation
// - While still catching truly missing 'output' properties
expect(Object.prototype.hasOwnProperty.call({ output: '' }, 'output')).toBe(true);
expect(Object.prototype.hasOwnProperty.call({ output: 0 }, 'output')).toBe(true);
expect(Object.prototype.hasOwnProperty.call({ output: false }, 'output')).toBe(true);
expect(Object.prototype.hasOwnProperty.call({ output: null }, 'output')).toBe(true);
// But should still fail for missing properties
expect(Object.prototype.hasOwnProperty.call({ foo: 'bar' }, 'output')).toBe(false);
expect(Object.prototype.hasOwnProperty.call({}, 'output')).toBe(false);
});
});
});