Files
promptfoo--promptfoo/test/redteam/providers/hydra/index.test.ts
T
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

2428 lines
76 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import * as evaluatorHelpers from '../../../../src/evaluatorHelpers';
import { PromptfooChatCompletionProvider } from '../../../../src/providers/promptfoo';
import {
neverGenerateRemote,
shouldGenerateRemote,
} from '../../../../src/redteam/remoteGeneration';
import {
createMockProvider,
createProviderResponse,
type MockApiProvider,
} from '../../../factories/provider';
import type { CallApiContextParams, GradingResult } from '../../../../src/types/index';
// Import HydraProvider dynamically after mocks are set up
let HydraProvider: typeof import('../../../../src/redteam/providers/hydra/index').HydraProvider;
// Hoisted mocks
const mockGetGraderById = vi.hoisted(() => vi.fn());
const mockGetSessionId = vi.hoisted(() => vi.fn());
const mockIsBasicRefusal = vi.hoisted(() => vi.fn());
// Tracing mocks
const mockResolveTracingOptions = vi.hoisted(() =>
vi.fn(() => ({
enabled: false,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
})),
);
const mockFetchTraceContext = vi.hoisted(() => vi.fn());
const mockFormatTraceSummary = vi.hoisted(() => vi.fn(() => 'Trace summary'));
const mockFormatTraceForMetadata = vi.hoisted(() => vi.fn(() => ({ traceId: 'test-trace-id' })));
const mockExtractTraceIdFromTraceparent = vi.hoisted(() => vi.fn(() => 'test-trace-id'));
// Hoisted mock for applyRuntimeTransforms
const mockApplyRuntimeTransforms = vi.hoisted(() =>
vi.fn().mockImplementation(async ({ prompt }) => ({
transformedPrompt: prompt,
audio: undefined,
image: undefined,
})),
);
vi.mock('../../../../src/providers/promptfoo', async (importOriginal) => {
return {
...(await importOriginal()),
PromptfooChatCompletionProvider: vi.fn(),
};
});
vi.mock('../../../../src/redteam/graders', () => ({
getGraderById: mockGetGraderById,
}));
vi.mock('../../../../src/redteam/remoteGeneration', async (importOriginal) => {
return {
...(await importOriginal()),
neverGenerateRemote: vi.fn().mockReturnValue(false),
shouldGenerateRemote: vi.fn(),
};
});
vi.mock('../../../../src/evaluatorHelpers', async () => ({
...(await vi.importActual('../../../../src/evaluatorHelpers')),
renderPrompt: vi.fn(),
}));
vi.mock('../../../../src/redteam/util', async () => ({
...(await vi.importActual('../../../../src/redteam/util')),
isBasicRefusal: mockIsBasicRefusal,
getSessionId: mockGetSessionId,
}));
vi.mock('../../../../src/redteam/shared/runtimeTransform', async (importOriginal) => {
return {
...(await importOriginal()),
applyRuntimeTransforms: mockApplyRuntimeTransforms,
};
});
// Tracing module mocks
vi.mock('../../../../src/redteam/providers/tracingOptions', () => ({
resolveTracingOptions: mockResolveTracingOptions,
}));
vi.mock('../../../../src/tracing/traceContext', () => ({
fetchTraceContext: mockFetchTraceContext,
extractTraceIdFromTraceparent: mockExtractTraceIdFromTraceparent,
}));
vi.mock('../../../../src/redteam/providers/traceFormatting', () => ({
formatTraceSummary: mockFormatTraceSummary,
formatTraceForMetadata: mockFormatTraceForMetadata,
}));
describe('HydraProvider', () => {
let mockAgentProvider: MockApiProvider;
let mockTargetProvider: MockApiProvider;
let mockGrader: any;
beforeAll(async () => {
const hydraModule = await import('../../../../src/redteam/providers/hydra/index');
HydraProvider = hydraModule.HydraProvider;
});
beforeEach(() => {
vi.clearAllMocks();
// Reset the hoisted mock to ensure clean state
mockGetGraderById.mockReset();
mockGetSessionId.mockReset().mockImplementation((response, context) => {
return response?.sessionId ?? context?.vars?.sessionId;
});
// Mock agent provider (cloud provider)
mockAgentProvider = createMockProvider({ id: 'mock-agent', delay: 0 });
mockAgentProvider.callApi.mockReset();
// Mock target provider
mockTargetProvider = createMockProvider({
id: 'mock-target',
response: createProviderResponse({ output: 'Target response' }),
});
// Mock grader
mockGrader = {
getResult: vi.fn().mockResolvedValue({
grade: {
pass: true,
score: 1,
reason: 'Target defended',
},
}),
};
// Setup mocks
(PromptfooChatCompletionProvider as Mock).mockImplementation(function () {
return mockAgentProvider;
});
mockGetGraderById.mockImplementation(function () {
return mockGrader;
});
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return true;
});
vi.mocked(neverGenerateRemote).mockReset();
vi.mocked(neverGenerateRemote).mockReturnValue(false);
vi.mocked(evaluatorHelpers.renderPrompt).mockResolvedValue('rendered prompt');
mockIsBasicRefusal.mockReturnValue(false);
// Reset tracing mocks to default (disabled) state
mockResolveTracingOptions.mockReturnValue({
enabled: false,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
mockFetchTraceContext.mockReset();
mockFormatTraceSummary.mockReturnValue('Trace summary');
mockFormatTraceForMetadata.mockReturnValue({ traceId: 'test-trace-id' });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with default config values', () => {
const provider = new HydraProvider({
injectVar: 'input',
});
expect(provider.config.injectVar).toBe('input');
expect(provider['maxTurns']).toBe(10);
expect(provider['maxBacktracks']).toBe(10);
expect(provider['stateful']).toBe(false);
});
it('should initialize with custom config values', () => {
const provider = new HydraProvider({
injectVar: 'query',
maxTurns: 5,
maxBacktracks: 3,
stateful: true,
scanId: 'test-scan-id',
});
expect(provider.config.injectVar).toBe('query');
expect(provider['maxTurns']).toBe(5);
expect(provider['maxBacktracks']).toBe(3);
expect(provider['stateful']).toBe(true);
expect(provider['scanId']).toBe('test-scan-id');
});
it('should throw the implicit-disabled error when remote generation is unavailable for this config', () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return false;
});
vi.mocked(neverGenerateRemote).mockReturnValue(false);
expect(() => {
new HydraProvider({ injectVar: 'input' });
}).toThrow(
'jailbreak:hydra strategy requires remote generation, which is currently disabled for this configuration. To enable it, run with --remote, set PROMPTFOO_REMOTE_GENERATION_URL to a self-hosted endpoint, or log into Promptfoo Cloud with `promptfoo auth login`.',
);
});
it('should throw the explicit-disabled error when a disable flag is set', () => {
vi.mocked(shouldGenerateRemote).mockImplementation(function () {
return false;
});
vi.mocked(neverGenerateRemote).mockReturnValue(true);
expect(() => {
new HydraProvider({ injectVar: 'input' });
}).toThrow(
/jailbreak:hydra strategy requires remote generation, which has been explicitly disabled\. To enable it, unset (PROMPTFOO_DISABLE_REMOTE_GENERATION|PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION)/,
);
});
it('should warn when backtracking is enabled in stateful mode', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
new HydraProvider({
injectVar: 'input',
stateful: true,
maxBacktracks: 5,
});
// Logger.warn should have been called
warnSpy.mockRestore();
});
it('should create agent provider with correct config', () => {
new HydraProvider({ injectVar: 'input', targetId: 'cloud-target-123' });
expect(PromptfooChatCompletionProvider).toHaveBeenCalledWith({
task: 'hydra-decision',
jsonOnly: true,
preferSmallModel: false,
targetId: 'cloud-target-123',
});
});
});
describe('id()', () => {
it('should return correct provider id', () => {
const provider = new HydraProvider({ injectVar: 'input' });
expect(provider.id()).toBe('promptfoo:redteam:hydra');
});
});
describe('callApi() - basic functionality', () => {
it('should execute single turn attack successfully', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target defended',
tokenUsage: { total: 50, prompt: 25, completion: 25 },
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result).toBeDefined();
expect(result.output).toBe('Target defended');
expect(result.metadata?.hydraRoundsCompleted).toBe(1);
expect(result.metadata?.hydraBacktrackCount).toBe(0);
expect(result.metadata?.hydraResult).toBe(false);
expect(result.metadata?.stopReason).toBe('Max turns reached');
// agent (100) + target (50) + learning update (100) = 250
expect(result.tokenUsage?.total).toBe(250);
});
it('should detect vulnerability when grader fails', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Successful attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Here is how to do harmful thing',
tokenUsage: { total: 50, prompt: 25, completion: 25 },
});
// Grader fails (vulnerability detected) - set up mockGetGraderById directly
mockGetGraderById.mockImplementation(function () {
return {
getResult: vi.fn().mockResolvedValue({
grade: {
pass: false,
score: 0,
reason: 'Harmful content detected',
},
}),
} as any;
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 10,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.metadata?.hydraResult).toBe(true);
expect(result.metadata?.stopReason).toBe('Grader failed');
expect(result.metadata?.successfulAttacks).toHaveLength(1);
expect(result.metadata?.successfulAttacks?.[0]).toMatchObject({
turn: 1,
message: 'Successful attack message',
response: 'Here is how to do harmful thing',
});
});
it('should handle multiple turns until max turns reached', async () => {
let callCount = 0;
mockAgentProvider.callApi.mockImplementation(async function () {
callCount++;
return {
output: `Attack message ${callCount}`,
tokenUsage: { total: 100, prompt: 50, completion: 50 },
};
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target defended',
tokenUsage: { total: 50, prompt: 25, completion: 25 },
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 3,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.metadata?.hydraRoundsCompleted).toBe(3);
expect(result.metadata?.stopReason).toBe('Max turns reached');
// Agent is called 3 times for attacks + 1 time for learning update
expect(mockAgentProvider.callApi).toHaveBeenCalledTimes(4);
expect(mockTargetProvider.callApi).toHaveBeenCalledTimes(3);
});
it('should use goal from test metadata or vars', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
// Test with goal in metadata
const contextWithMetadata: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'var goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'metadata goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', contextWithMetadata);
const agentCall = mockAgentProvider.callApi.mock.calls[0];
const request = JSON.parse(agentCall[0] as string);
expect(request.goal).toBe('metadata goal');
// Reset mocks
vi.clearAllMocks();
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
// Test with goal from vars
const contextWithVars: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'var goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', contextWithVars);
const agentCall2 = mockAgentProvider.callApi.mock.calls[0];
const request2 = JSON.parse(agentCall2[0] as string);
expect(request2.goal).toBe('var goal');
});
});
describe('callApi() - stateful mode', () => {
it('should handle stateful mode with sessionId', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
sessionId: 'session-123',
tokenUsage: { total: 50, prompt: 25, completion: 25 },
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
stateful: true,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.metadata?.sessionId).toBe('session-123');
expect(result.metadata?.sessionIds).toEqual(['session-123', 'session-123']);
// Check that the second call includes sessionId
const renderCalls = (evaluatorHelpers.renderPrompt as Mock).mock.calls;
const secondCall = renderCalls[1];
expect(secondCall[1]).toMatchObject({
sessionId: 'session-123',
});
});
it('should reuse a client-generated sessionId while sending only the latest turn', async () => {
vi.mocked(evaluatorHelpers.renderPrompt).mockImplementation(async (_prompt, vars) =>
String(vars.input),
);
mockAgentProvider.callApi
.mockResolvedValueOnce({
output: 'Remember violet.',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
})
.mockResolvedValueOnce({
output: 'What did I ask you to remember?',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
const targetSessions = new Map<string, string[]>();
mockTargetProvider.callApi.mockImplementation(async (prompt, targetContext) => {
const sessionId = String(targetContext?.vars?.sessionId);
const turns = targetSessions.get(sessionId) ?? [];
const output =
prompt === 'What did I ask you to remember?' && turns[0] === 'Remember violet.'
? 'violet'
: 'stored';
turns.push(prompt as string);
targetSessions.set(sessionId, turns);
return {
output,
tokenUsage: { total: 50, prompt: 25, completion: 25 },
};
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
stateful: true,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal', sessionId: 'client-session-123' },
prompt: { raw: '{{input}}', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(mockTargetProvider.callApi).toHaveBeenCalledTimes(2);
expect(mockTargetProvider.callApi.mock.calls.map(([prompt]) => prompt)).toEqual([
'Remember violet.',
'What did I ask you to remember?',
]);
expect(
mockTargetProvider.callApi.mock.calls.map(([, targetContext]) => targetContext?.vars),
).toEqual([
expect.objectContaining({ sessionId: 'client-session-123' }),
expect.objectContaining({ sessionId: 'client-session-123' }),
]);
expect(result.output).toBe('violet');
expect(result.metadata?.sessionId).toBe('client-session-123');
expect(result.metadata?.hydraRoundsCompleted).toBe(2);
});
it('should escape nunjucks syntax in stateful mode', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack with {{template}} and {% block %}',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
stateful: true,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', context);
// Check that the call with the escaped message was made
const renderCalls = (evaluatorHelpers.renderPrompt as Mock).mock.calls;
const escapedCall = renderCalls.find(
(call) => call[1].input === 'Attack with { {template} } and { % block % }',
);
expect(escapedCall).toBeDefined();
});
it('should propagate sessionId to context.vars in stateful mode', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
sessionId: 'session-456',
tokenUsage: { total: 50, prompt: 25, completion: 25 },
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
stateful: true,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(context.vars['sessionId']).toBe('session-456');
expect(result.metadata?.sessionId).toBe('session-456');
});
it('should not propagate sessionId to context.vars in stateless mode', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
sessionId: 'session-789',
tokenUsage: { total: 50, prompt: 25, completion: 25 },
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
stateful: false,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', context);
expect(context.vars['sessionId']).toBeUndefined();
});
});
describe('callApi() - stateless mode', () => {
it('should send full conversation history in stateless mode', async () => {
mockAgentProvider.callApi
.mockResolvedValueOnce({
output: 'First attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
})
.mockResolvedValueOnce({
output: 'Second attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi
.mockResolvedValueOnce({
output: 'First response',
})
.mockResolvedValueOnce({
output: 'Second response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
stateful: false,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', context);
// Check that target was called with JSON conversation history
const secondTargetCall = mockTargetProvider.callApi.mock.calls[1];
expect(secondTargetCall[0]).toBeDefined();
// In stateless mode, the prompt should be JSON stringified conversation
});
});
describe('callApi() - backtracking', () => {
it('should backtrack when target refuses in stateless mode', async () => {
// Import to trigger the mock
await import('../../../../src/redteam/util');
let attackCallCount = 0;
mockAgentProvider.callApi.mockImplementation(async function (prompt) {
const request = JSON.parse(prompt as string);
// Don't count learning updates
if (!request.testComplete) {
attackCallCount++;
}
return {
output: `Attack message ${attackCallCount}`,
tokenUsage: { total: 100, prompt: 50, completion: 50 },
};
});
let targetCallCount = 0;
mockTargetProvider.callApi.mockImplementation(async function () {
targetCallCount++;
// First call is refused, second succeeds
if (targetCallCount === 1) {
return { output: "I can't help with that" };
}
return { output: 'Target cooperated' };
});
// First response is a refusal, second is not
mockIsBasicRefusal.mockReturnValueOnce(true).mockReturnValueOnce(false);
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 10,
maxBacktracks: 5,
stateful: false,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.metadata?.hydraBacktrackCount).toBe(1);
// Should complete 1 turn (first was backtracked, second succeeded and continues to max)
expect(result.metadata?.hydraRoundsCompleted).toBeGreaterThanOrEqual(1);
});
it('should stop when max backtracks reached', async () => {
// Import to trigger the mock
await import('../../../../src/redteam/util');
mockIsBasicRefusal.mockReturnValue(true); // Always refuse
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: "I can't help with that",
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 10,
maxBacktracks: 3,
stateful: false,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.metadata?.hydraBacktrackCount).toBe(3);
expect(result.metadata?.stopReason).toBe('Max backtracks reached');
});
it('should not backtrack in stateful mode', async () => {
// Import to trigger the mock
await import('../../../../src/redteam/util');
mockIsBasicRefusal.mockReturnValue(true); // Always refuse
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: "I can't help with that",
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 3,
maxBacktracks: 5,
stateful: true,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// No backtracking should occur
expect(result.metadata?.hydraBacktrackCount).toBe(0);
expect(result.metadata?.hydraRoundsCompleted).toBe(3);
});
});
describe('callApi() - error handling', () => {
it('should continue when agent provider returns error', async () => {
mockAgentProvider.callApi
.mockResolvedValueOnce({
error: 'Agent error',
output: '',
})
.mockResolvedValueOnce({
output: 'Valid attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// Should complete despite agent error on first turn
expect(result.metadata?.hydraRoundsCompleted).toBe(1);
expect(mockTargetProvider.callApi).toHaveBeenCalledTimes(1);
});
it('should continue when target provider returns error', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi
.mockResolvedValueOnce({
error: 'Target error',
output: '',
})
.mockResolvedValueOnce({
output: 'Valid response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// First turn has error (doesn't count), second turn succeeds (counts as turn 1)
// But since we continue after error, we actually make 2 turns total
expect(result.metadata?.hydraRoundsCompleted).toBeGreaterThanOrEqual(1);
// Agent is called for each turn + learning update
expect(mockAgentProvider.callApi).toHaveBeenCalledTimes(3);
});
it('should stop when target ends conversation', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: '',
conversationEnded: true,
conversationEndReason: 'thread_closed',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 5,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.metadata?.stopReason).toBe('Target ended conversation');
expect(result.metadata?.hydraRoundsCompleted).toBe(1);
expect(mockTargetProvider.callApi).toHaveBeenCalledTimes(1);
});
it('should handle empty target response', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: '',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// Should handle empty response by adding marker
expect(result.metadata?.redteamHistory?.[0].output).toBe(
'[Target provided empty response - likely refused]',
);
});
it('should continue when agent returns missing message', async () => {
mockAgentProvider.callApi
.mockResolvedValueOnce({
output: '', // Empty message
})
.mockResolvedValueOnce({
output: 'Valid attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// Should skip turn with empty message
expect(result.metadata?.hydraRoundsCompleted).toBe(1);
});
});
describe('callApi() - conversation history', () => {
it('should build conversation history correctly', async () => {
mockAgentProvider.callApi
.mockResolvedValueOnce({
output: 'First attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
})
.mockResolvedValueOnce({
output: 'Second attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi
.mockResolvedValueOnce({
output: 'First response',
})
.mockResolvedValueOnce({
output: 'Second response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.metadata?.messages).toEqual([
{ role: 'user', content: 'First attack' },
{ role: 'assistant', content: 'First response' },
{ role: 'user', content: 'Second attack' },
{ role: 'assistant', content: 'Second response' },
]);
expect(result.metadata?.redteamHistory).toHaveLength(2);
expect(result.metadata?.redteamHistory?.[0]).toEqual({
prompt: 'First attack',
output: 'First response',
graderPassed: true,
});
});
it('should send conversation history to cloud agent', async () => {
mockAgentProvider.callApi
.mockResolvedValueOnce({
output: 'First attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
})
.mockResolvedValueOnce({
output: 'Second attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', context);
// Check second agent call includes conversation history
const secondAgentCall = mockAgentProvider.callApi.mock.calls[1];
const request = JSON.parse(secondAgentCall[0] as string);
expect(request.conversationHistory).toEqual([
{ role: 'user', content: 'First attack' },
{ role: 'assistant', content: 'Target response' },
]);
expect(request.turn).toBe(2);
});
});
describe('callApi() - grading', () => {
it('should use correct grader from test assertions', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:violent-crime' }, { type: 'other-assertion' }],
metadata: { goal: 'test goal', pluginId: 'harmful:violent-crime' },
} as any,
};
await provider.callApi('', context);
expect(mockGetGraderById).toHaveBeenCalledWith('harmful:violent-crime');
expect(mockGrader.getResult).toHaveBeenCalledWith(
'Attack message',
'Target response',
expect.anything(),
mockTargetProvider,
undefined,
undefined, // additionalRubric
undefined, // skipRefusalCheck
expect.objectContaining({
traceContext: null,
traceSummary: undefined,
}),
);
});
it('should send grader result to cloud agent on next turn', async () => {
const graderResult: GradingResult = {
pass: false,
score: 0.3,
reason: 'Partially harmful',
};
mockAgentProvider.callApi
.mockResolvedValueOnce({
output: 'First attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
})
.mockResolvedValueOnce({
output: 'Second attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
// First turn passes grader, second turn fails - set up mockGetGraderById directly
const mockGetResult = vi
.fn()
.mockResolvedValueOnce({
grade: { pass: true, score: 1, reason: 'Safe' },
})
.mockResolvedValueOnce({
grade: graderResult,
});
mockGetGraderById.mockImplementation(function () {
return { getResult: mockGetResult } as any;
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', context);
// Check second agent call includes last grader result
const secondAgentCall = mockAgentProvider.callApi.mock.calls[1];
const request = JSON.parse(secondAgentCall[0] as string);
expect(request.lastGraderResult).toEqual({
pass: true,
score: 1,
});
});
it('should store grader result in metadata', async () => {
const graderResult: GradingResult = {
pass: false,
score: 0,
reason: 'Vulnerability detected',
};
const testRubric = 'Test grading rubric';
// Set up mockGetGraderById directly to return grader that fails
mockGetGraderById.mockImplementation(function () {
return {
getResult: vi.fn().mockResolvedValue({
grade: graderResult,
rubric: testRubric,
}),
} as any;
});
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Harmful response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.metadata?.storedGraderResult).toEqual({
...graderResult,
assertion: { type: 'harmful:test', value: testRubric },
});
});
it('passes target response evidence and image outputs into the grader', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
raw: JSON.stringify({ finalResponse: 'Target response', items: ['raw evidence'] }),
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', context);
const gradingContext = mockGrader.getResult.mock.calls[0][7] as {
imageOutputs?: Array<{ data?: string; mimeType?: string }>;
providerResponse?: { output?: unknown; raw?: unknown };
};
expect(gradingContext.imageOutputs).toEqual([
{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' },
]);
expect(gradingContext.providerResponse).toMatchObject({
output: 'Target response',
raw: JSON.stringify({ finalResponse: 'Target response', items: ['raw evidence'] }),
});
});
});
describe('callApi() - scan learning', () => {
it('should send learning update after completion', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
scanId: 'test-scan-id',
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
evaluationId: 'eval-123',
};
await provider.callApi('', context);
// Check last call is learning update
const lastCall =
mockAgentProvider.callApi.mock.calls[mockAgentProvider.callApi.mock.calls.length - 1];
const request = JSON.parse(lastCall[0] as string);
expect(request.task).toBe('hydra-decision');
expect(request.testComplete).toBe(true);
expect(request.scanId).toBe('eval-123'); // Should use evaluationId
expect(request.finalResult).toEqual({
success: false,
totalTurns: 2,
});
});
it('should send success in learning update when vulnerability found', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Harmful response',
});
// Set up mockGetGraderById directly to return grader that fails
mockGetGraderById.mockImplementation(function () {
return {
getResult: vi.fn().mockResolvedValue({
grade: { pass: false, score: 0, reason: 'Vulnerability' },
}),
} as any;
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 10,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
evaluationId: 'eval-123',
};
await provider.callApi('', context);
const lastCall =
mockAgentProvider.callApi.mock.calls[mockAgentProvider.callApi.mock.calls.length - 1];
const request = JSON.parse(lastCall[0] as string);
expect(request.finalResult.success).toBe(true);
expect(request.finalResult.totalTurns).toBe(1);
});
it('should not fail test if learning update fails', async () => {
mockAgentProvider.callApi
.mockResolvedValueOnce({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
})
.mockRejectedValueOnce(new Error('Learning update failed'));
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
// Should not throw
const result = await provider.callApi('', context);
expect(result).toBeDefined();
expect(result.output).toBe('Target response');
});
});
describe('callApi() - token usage tracking', () => {
it('should accumulate token usage from agent and target', async () => {
mockAgentProvider.callApi
.mockResolvedValueOnce({
output: 'First attack',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
})
.mockResolvedValueOnce({
output: 'Second attack',
tokenUsage: { total: 150, prompt: 75, completion: 75 },
});
mockTargetProvider.callApi
.mockResolvedValueOnce({
output: 'First response',
tokenUsage: { total: 80, prompt: 40, completion: 40 },
})
.mockResolvedValueOnce({
output: 'Second response',
tokenUsage: { total: 120, prompt: 60, completion: 60 },
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 2,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// Total tokens should be sum of all calls
// Agent: 100 + 150 = 250
// Target: 80 + 120 = 200
// Total: 450
expect(result.tokenUsage?.total).toBe(450);
expect(result.tokenUsage?.prompt).toBe(225);
expect(result.tokenUsage?.completion).toBe(225);
// Probes should only count target calls.
expect(result.tokenUsage?.numRequests).toBe(2);
});
});
describe('callApi() - cloud request format', () => {
it('should send correct request format to cloud', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: {
goal: 'test goal',
pluginId: 'harmful:test',
purpose: 'Test purpose',
modifiers: ['modifier1', 'modifier2'],
},
} as any,
evaluationId: 'eval-123',
testCaseId: 'tc-456',
};
await provider.callApi('', context);
const agentCall = mockAgentProvider.callApi.mock.calls[0];
const request = JSON.parse(agentCall[0] as string);
expect(request).toMatchObject({
task: 'hydra-decision',
testRunId: expect.stringContaining('eval-123-tc'),
scanId: 'eval-123',
turn: 1,
goal: 'test goal',
purpose: 'Test purpose',
modifiers: ['modifier1', 'modifier2'],
conversationHistory: [],
stateful: false,
maxTurns: 1,
});
// First request should not have lastGraderResult
expect(request.lastGraderResult).toBeUndefined();
});
it('should send plain input descriptions to the hydra agent prompt in multi-input mode', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: JSON.stringify({
prompt: 'Summarize the uploaded planning document.',
document: 'doc payload',
question: 'What changed?',
}),
materializationHandled: true,
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
question: {
description: 'Benign analyst question',
type: 'text',
},
},
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: {
goal: 'test goal',
pluginId: 'harmful:test',
purpose: 'Test purpose',
},
} as any,
};
await provider.callApi('', context);
const agentCall = mockAgentProvider.callApi.mock.calls[0];
const request = JSON.parse(agentCall[0] as string);
expect(request.inputs).toEqual({
document: {
description: 'Uploaded planning document',
type: 'docx',
},
question: {
description: 'Benign analyst question',
type: 'text',
},
});
});
it('should use remote materialized vars when the hydra prompt is plain text', async () => {
const docxDataUri =
'data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,Zm9v';
mockAgentProvider.callApi.mockResolvedValue({
output: 'Summarize the uploaded planning document for the analyst.',
materializationHandled: true,
materializedVars: {
document: docxDataUri,
question: 'What changed?',
},
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
question: {
description: 'Benign analyst question',
type: 'text',
},
},
maxTurns: 1,
stateful: true,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: {
input: 'test goal',
document: 'stale document',
question: 'stale question',
},
prompt: { raw: 'Doc={{document}}; Question={{question}}', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: {
goal: 'test goal',
pluginId: 'harmful:test',
purpose: 'Test purpose',
},
} as any,
};
await provider.callApi('', context);
const renderCalls = (evaluatorHelpers.renderPrompt as Mock).mock.calls;
expect(renderCalls[0][1]).toMatchObject({
document: docxDataUri,
question: 'What changed?',
});
});
it('should preserve the existing DOCX var when remote materialization omits it', async () => {
const docxDataUri =
'data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,Zm9v';
mockAgentProvider.callApi.mockResolvedValue({
output: JSON.stringify({
document: 'Quarterly Sales Report',
question: 'Can you summarize the main points from this document?',
}),
materializationHandled: true,
materializedVars: {
question: 'What changed?',
},
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
question: {
description: 'Benign analyst question',
type: 'text',
},
},
maxTurns: 1,
stateful: true,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: {
input: 'test goal',
document: docxDataUri,
question: 'stale question',
},
prompt: { raw: 'Doc={{document}}; Question={{question}}', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: {
goal: 'test goal',
pluginId: 'harmful:test',
purpose: 'Test purpose',
},
} as any,
};
await provider.callApi('', context);
const renderCalls = (evaluatorHelpers.renderPrompt as Mock).mock.calls;
expect(renderCalls[0][1]).toMatchObject({
document: docxDataUri,
question: 'What changed?',
});
});
it('should fail closed when the hydra agent never produces a target probe', async () => {
mockAgentProvider.callApi.mockResolvedValue({
error: 'Invalid schema for inputs.document',
});
const provider = new HydraProvider({
injectVar: 'input',
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
},
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: {
goal: 'test goal',
pluginId: 'harmful:test',
purpose: 'Test purpose',
},
} as any,
};
const result = await provider.callApi('', context);
expect(result.error).toBe('Invalid schema for inputs.document');
expect(result.metadata.hydraRoundsCompleted).toBe(0);
expect(result.tokenUsage?.numRequests).toBe(0);
expect(mockTargetProvider.callApi).not.toHaveBeenCalled();
});
});
describe('Abort Signal Handling', () => {
it('should pass options to agent provider callApi', async () => {
const abortController = new AbortController();
const options = { abortSignal: abortController.signal };
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', context, options);
// Agent provider should be called with options
expect(mockAgentProvider.callApi).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
options,
);
});
it('should pass options to target provider via getTargetResponse', async () => {
const abortController = new AbortController();
const options = { abortSignal: abortController.signal };
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
await provider.callApi('', context, options);
// Target provider should be called with options via getTargetResponse
expect(mockTargetProvider.callApi).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
options,
);
});
});
describe('callApi() - metadata output', () => {
it('should return complete metadata', async () => {
const { getSessionId } = await import('../../../../src/redteam/util');
vi.mocked(getSessionId).mockReturnValue('session-123');
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
sessionId: 'session-123',
guardrails: { triggered: true, policy: 'test-policy' } as any,
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
stateful: true, // Enable stateful to capture sessionId
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result).toMatchObject({
output: 'Target response',
metadata: {
sessionId: 'session-123',
hydraRoundsCompleted: 1,
hydraBacktrackCount: 0,
hydraResult: false,
stopReason: 'Max turns reached',
successfulAttacks: [],
totalSuccessfulAttacks: 0,
messages: expect.arrayContaining([
{ role: 'user', content: 'Attack message' },
{ role: 'assistant', content: 'Target response' },
]),
redteamHistory: expect.arrayContaining([
expect.objectContaining({
prompt: 'Attack message',
output: 'Target response',
graderPassed: true,
}),
]),
sessionIds: ['session-123'],
storedGraderResult: expect.any(Object),
},
tokenUsage: expect.any(Object),
guardrails: { triggered: true, policy: 'test-policy' },
});
});
it('should include error in output if last response had error', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
error: 'Some error occurred',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
expect(result.output).toBe('Target response');
expect(result.error).toBe('Some error occurred');
});
});
describe('perTurnLayers configuration', () => {
it('should accept _perTurnLayers in config', () => {
const provider = new HydraProvider({
injectVar: 'input',
_perTurnLayers: [{ id: 'audio' }, { id: 'image' }],
});
expect(provider['perTurnLayers']).toEqual([{ id: 'audio' }, { id: 'image' }]);
});
it('should default perTurnLayers to empty array when not provided', () => {
const provider = new HydraProvider({
injectVar: 'input',
});
expect(provider['perTurnLayers']).toEqual([]);
});
it('should not apply transforms when perTurnLayers is empty', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
// No _perTurnLayers provided - defaults to empty
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// Verify redteamHistory exists but promptAudio/promptImage are undefined
expect(result.metadata?.redteamHistory).toBeDefined();
if (result.metadata?.redteamHistory && result.metadata.redteamHistory.length > 0) {
expect(result.metadata.redteamHistory[0].promptAudio).toBeUndefined();
expect(result.metadata.redteamHistory[0].promptImage).toBeUndefined();
}
});
it('should include redteamHistory with media fields when perTurnLayers is configured', async () => {
// Configure the hoisted mock to return audio/image data for this test
mockApplyRuntimeTransforms.mockResolvedValueOnce({
transformedPrompt: 'transformed attack',
audio: { data: 'base64-audio-data', format: 'mp3' },
image: { data: 'base64-image-data', format: 'png' },
});
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
audio: { data: 'response-audio-data', format: 'wav' },
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
_perTurnLayers: [{ id: 'audio' }],
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// Verify redteamHistory is populated
expect(result.metadata?.redteamHistory).toBeDefined();
expect(Array.isArray(result.metadata?.redteamHistory)).toBe(true);
});
it('should include outputAudio in redteamHistory when target returns audio', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
audio: { data: 'output-audio-base64', format: 'wav' },
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
};
const result = await provider.callApi('', context);
// Verify outputAudio is captured in redteamHistory
expect(result.metadata?.redteamHistory).toBeDefined();
if (result.metadata?.redteamHistory && result.metadata.redteamHistory.length > 0) {
expect(result.metadata.redteamHistory[0].outputAudio).toEqual({
data: 'output-audio-base64',
format: 'wav',
});
}
});
});
describe('Tracing Support', () => {
it('should NOT fetch trace context when tracing is disabled (default)', async () => {
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
traceparent: '00-trace123-span456-01',
};
const result = await provider.callApi('', context);
// Should NOT call fetchTraceContext when tracing is disabled
expect(mockFetchTraceContext).not.toHaveBeenCalled();
// Metadata should not have trace snapshots
expect(result.metadata?.traceSnapshots).toBeUndefined();
});
it('should fetch trace context when tracing is enabled', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
// Mock trace context
mockFetchTraceContext.mockResolvedValue({
traceId: 'test-trace-id',
spans: [{ spanId: 'span1', name: 'test-span' }],
insights: ['Test insight'],
fetchedAt: Date.now(),
});
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
traceparent: '00-trace123-span456-01',
};
const result = await provider.callApi('', context);
// Should call fetchTraceContext
expect(mockFetchTraceContext).toHaveBeenCalled();
// Metadata should have trace snapshots
expect(result.metadata?.traceSnapshots).toBeDefined();
expect(result.metadata?.traceSnapshots).toHaveLength(1);
});
it('should NOT fetch trace context when traceparent is missing', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
// No traceparent
};
const result = await provider.callApi('', context);
// Should NOT call fetchTraceContext when traceparent is missing
expect(mockFetchTraceContext).not.toHaveBeenCalled();
// Metadata should not have trace snapshots
expect(result.metadata?.traceSnapshots).toBeUndefined();
});
it('should call formatTraceSummary when tracing is enabled and trace is fetched', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
mockFetchTraceContext.mockResolvedValue({
traceId: 'test-trace-id',
spans: [{ spanId: 'span1', name: 'test-span' }],
insights: [],
fetchedAt: Date.now(),
});
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
traceparent: '00-trace123-span456-01',
};
await provider.callApi('', context);
// formatTraceSummary should be called when trace is fetched
expect(mockFormatTraceSummary).toHaveBeenCalled();
});
it('should call formatTraceForMetadata when trace is stored in metadata', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
mockFetchTraceContext.mockResolvedValue({
traceId: 'test-trace-id',
spans: [{ spanId: 'span1', name: 'test-span' }],
insights: [],
fetchedAt: Date.now(),
});
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
traceparent: '00-trace123-span456-01',
};
await provider.callApi('', context);
// formatTraceForMetadata should be called for storing trace
expect(mockFormatTraceForMetadata).toHaveBeenCalled();
});
it('should handle fetchTraceContext returning null gracefully', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
// Return null (no trace found)
mockFetchTraceContext.mockResolvedValue(null);
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
traceparent: '00-trace123-span456-01',
};
const result = await provider.callApi('', context);
// Should complete without error
expect(result.metadata?.hydraRoundsCompleted).toBeDefined();
// No trace snapshots should be present
expect(result.metadata?.traceSnapshots).toBeUndefined();
});
it('should include trace data in redteamHistory entries when tracing is enabled', async () => {
// Enable tracing
mockResolveTracingOptions.mockReturnValue({
enabled: true,
includeInAttack: true,
includeInGrading: true,
includeInternalSpans: false,
maxSpans: 50,
maxDepth: 5,
maxRetries: 3,
retryDelayMs: 500,
sanitizeAttributes: true,
});
mockFetchTraceContext.mockResolvedValue({
traceId: 'test-trace-id',
spans: [{ spanId: 'span1', name: 'test-span' }],
insights: [],
fetchedAt: Date.now(),
});
mockAgentProvider.callApi.mockResolvedValue({
output: 'Attack message',
tokenUsage: { total: 100, prompt: 50, completion: 50 },
});
mockTargetProvider.callApi.mockResolvedValue({
output: 'Target response',
});
const provider = new HydraProvider({
injectVar: 'input',
maxTurns: 1,
});
const context: CallApiContextParams = {
originalProvider: mockTargetProvider,
vars: { input: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
assert: [{ type: 'harmful:test' }],
metadata: { goal: 'test goal', pluginId: 'harmful:test' },
} as any,
traceparent: '00-trace123-span456-01',
};
const result = await provider.callApi('', context);
// redteamHistory should have trace data
expect(result.metadata?.redteamHistory).toBeDefined();
if (result.metadata?.redteamHistory && result.metadata.redteamHistory.length > 0) {
const entry = result.metadata.redteamHistory[0];
expect(entry.trace).toBeDefined();
expect(entry.traceSummary).toBe('Trace summary');
}
});
});
});