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

1776 lines
55 KiB
TypeScript

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import RedteamGoatProvider from '../../../src/redteam/providers/goat';
import { getRemoteGenerationUrl } from '../../../src/redteam/remoteGeneration';
import { createMockProvider } from '../../factories/provider';
import type {
ApiProvider,
AtomicTestCase,
CallApiContextParams,
Prompt,
} from '../../../src/types/index';
// Mock the graders module at the top level
const mockGrader = {
getResult: vi.fn(),
};
const mockGetGraderById = vi.fn().mockReturnValue(mockGrader);
vi.mock('../../../src/globalConfig/accounts', async (importOriginal) => ({
...(await importOriginal()),
isLoggedIntoCloud: vi.fn().mockReturnValue(true),
}));
vi.mock('../../../src/redteam/graders', async (importOriginal) => {
return {
...(await importOriginal()),
getGraderById: mockGetGraderById,
};
});
vi.mock('../../../src/util/server', async (importOriginal) => {
return {
...(await importOriginal()),
checkServerFeatureSupport: vi.fn(() => Promise.resolve(false)),
};
});
describe('RedteamGoatProvider', () => {
let mockFetch: Mock;
let tempDir: string;
// Helper function to create a mock target provider
const createMockTargetProvider = (
outputValue: any = 'target response',
tokenUsage: any = {},
responseOverrides: Record<string, unknown> = {},
) =>
createMockProvider({
response: {
output: outputValue,
tokenUsage,
...responseOverrides,
},
});
// Helper function to create a mock context
const createMockContext = (
targetProvider: ApiProvider,
vars: Record<string, any> = { goal: 'test goal' },
testConfig?: any,
): CallApiContextParams => ({
originalProvider: targetProvider,
vars,
prompt: { raw: 'test prompt', label: 'test' },
test: testConfig,
});
const createTempFile = (name: string, content: string): string => {
const filePath = path.join(tempDir, name);
fs.writeFileSync(filePath, content, 'utf8');
return filePath;
};
const collectJsonStrings = (value: unknown): string[] => {
if (typeof value === 'string') {
return [value];
}
if (Array.isArray(value)) {
return value.flatMap((item) => collectJsonStrings(item));
}
if (typeof value === 'object' && value !== null) {
return Object.entries(value).flatMap(([key, item]) => [key, ...collectJsonStrings(item)]);
}
return [];
};
const getRenderedTargetPrompt = (targetProvider: ApiProvider): string =>
(targetProvider.callApi as Mock).mock.calls[0][0] as string;
const getRenderedTargetPromptText = (targetProvider: ApiProvider): string => {
const renderedPrompt = getRenderedTargetPrompt(targetProvider);
try {
return collectJsonStrings(JSON.parse(renderedPrompt)).join('\n');
} catch {
return renderedPrompt;
}
};
beforeEach(() => {
vi.clearAllMocks();
mockGetGraderById.mockReset();
mockGetGraderById.mockReturnValue(mockGrader);
mockGrader.getResult.mockReset();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-goat-provider-'));
mockFetch = vi.fn().mockImplementation(async function () {
return {
json: async () => ({
materializationHandled: true,
message: { role: 'assistant', content: 'test response' },
}),
ok: true,
};
});
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('should initialize with required config', () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
});
expect(provider.id()).toBe('promptfoo:redteam:goat');
});
it('should throw error if injectVar is missing', () => {
expect(() => {
new RedteamGoatProvider({});
}).toThrow('Expected injectVar to be set');
});
it('should initialize with all config options', () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
stateful: true,
excludeTargetOutputFromAgenticAttackGeneration: true,
});
expect(provider.config).toEqual({
injectVar: 'goal',
maxTurns: 3,
stateful: true,
excludeTargetOutputFromAgenticAttackGeneration: true,
continueAfterSuccess: false,
});
});
it('should enforce maxCharsPerMessage from provider config', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxCharsPerMessage: 5,
maxTurns: 1,
stateful: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider);
await provider.callApi('test prompt', context);
expect(targetProvider.callApi).not.toHaveBeenCalled();
});
it('should preserve an explicit maxTurns value of 0', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 0,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
expect(provider.config.maxTurns).toBe(0);
expect(mockFetch).not.toHaveBeenCalled();
expect(targetProvider.callApi).not.toHaveBeenCalled();
expect(result.output).toBe('');
expect(result.prompt).toBe('');
expect(result.metadata?.stopReason).toBe('Max turns reached');
expect(result.metadata?.successfulAttacks).toEqual([]);
expect(result.metadata?.totalSuccessfulAttacks).toBe(0);
});
it('should default stateful to false when not specified', () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
});
expect(provider.config.stateful).toBe(false);
});
it('should handle stateful behavior when set to true', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
stateful: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider);
await provider.callApi('test prompt', context);
const lastCallBody = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(lastCallBody.messages).toBeDefined();
});
it('should include target context in remote agentic task requests', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
targetId: 'cloud-target-123',
});
await provider.callApi('test prompt', createMockContext(createMockTargetProvider()));
const requestBody = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(requestBody).toMatchObject({
targetId: 'cloud-target-123',
task: 'goat',
});
});
it('should not dereference file:// paths in remote attacker messages', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
stateful: true,
});
const fileContent = 'local secret from attacker-controlled file var';
const filePath = createTempFile('attacker-goal.txt', fileContent);
const fileUrl = `file://${filePath}`;
mockFetch.mockResolvedValue({
json: async () => ({
message: { role: 'user', content: fileUrl },
}),
ok: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider, { goal: 'initial goal' }, undefined);
context.prompt = { raw: 'Attack: {{goal}}', label: 'test' };
await provider.callApi('test prompt', context);
const renderedPromptText = getRenderedTargetPromptText(targetProvider);
expect(renderedPromptText).toContain(fileUrl);
expect(renderedPromptText).not.toContain(fileContent);
});
it('should not dereference multi-input attacker vars and should preserve JSON escaping', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
stateful: true,
inputs: {
email: 'The user email',
notes: 'Additional attacker notes',
},
});
const fileContent = 'multi-input local secret';
const filePath = createTempFile('multi-input-email.txt', fileContent);
const fileUrl = `file://${filePath}`;
const packageRef = 'package:@promptfoo/fake:getSecret';
const attackerGoal = 'hello "quoted"\nnext line';
mockFetch.mockResolvedValue({
json: async () => ({
materializationHandled: true,
message: {
role: 'user',
content: JSON.stringify({
prompt: attackerGoal,
email: fileUrl,
notes: packageRef,
}),
},
}),
ok: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(
targetProvider,
{ goal: 'initial goal', email: 'safe@example.com', notes: 'safe notes' },
undefined,
);
context.prompt = {
raw: JSON.stringify({
role: 'user',
content: 'Goal={{goal}}\nEmail={{email}}\nNotes={{notes}}',
}),
label: 'test',
};
await provider.callApi('test prompt', context);
expect(targetProvider.callApi).toHaveBeenCalledTimes(1);
const renderedPrompt = getRenderedTargetPrompt(targetProvider);
const renderedPromptText = getRenderedTargetPromptText(targetProvider);
const parsedPrompt = JSON.parse(renderedPrompt);
expect(parsedPrompt).toEqual({
role: 'user',
content: `Goal=${attackerGoal}\nEmail=${fileUrl}\nNotes=${packageRef}`,
});
expect(renderedPromptText).toContain(fileUrl);
expect(renderedPromptText).toContain(packageRef);
expect(renderedPromptText).not.toContain(fileContent);
});
it('should preserve filters on attacker prompts without evaluating attacker templates', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
stateful: true,
});
const attackerGoal = ' do {{7*7}} ';
mockFetch.mockResolvedValue({
json: async () => ({
message: { role: 'user', content: attackerGoal },
}),
ok: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider, { goal: 'initial goal' }, undefined);
context.prompt = { raw: 'Attack: {{goal | trim | upper}}', label: 'test' };
await provider.callApi('test prompt', context);
expect(getRenderedTargetPromptText(targetProvider)).toBe('Attack: DO {{7*7}}');
});
it('should preserve filters on file:// attacker prompts without dereferencing them', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
stateful: true,
});
const fileContent = 'filtered local secret';
const filePath = createTempFile('filtered-goal.txt', fileContent);
const fileUrl = `file://${filePath}`;
mockFetch.mockResolvedValue({
json: async () => ({
message: { role: 'user', content: ` ${fileUrl}\n` },
}),
ok: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider, { goal: 'initial goal' }, undefined);
context.prompt = { raw: 'Attack: {{goal | trim}}', label: 'test' };
await provider.callApi('test prompt', context);
const renderedPromptText = getRenderedTargetPromptText(targetProvider);
expect(renderedPromptText).toBe(`Attack: ${fileUrl}`);
expect(renderedPromptText).not.toContain(fileContent);
});
it('should preserve filters on multi-input attacker vars', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
stateful: true,
inputs: {
email: 'The user email',
},
});
mockFetch.mockResolvedValue({
json: async () => ({
materializationHandled: true,
message: {
role: 'user',
content: JSON.stringify({
prompt: ' Keep {{7*7}} ',
email: ' USER@EXAMPLE.COM ',
}),
},
}),
ok: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(
targetProvider,
{ goal: 'initial goal', email: 'safe@example.com' },
undefined,
);
context.prompt = {
raw: 'Goal={{goal | trim}}; Email={{email | trim | lower}}',
label: 'test',
};
await provider.callApi('test prompt', context);
expect(getRenderedTargetPromptText(targetProvider)).toBe(
'Goal=Keep {{7*7}}; Email=user@example.com',
);
});
it('should preserve fallback multi-input vars when remote materialization is partial', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
stateful: true,
inputs: {
document: {
description: 'Uploaded planning document',
type: 'docx',
},
notes: 'Supplemental analyst notes',
},
});
mockFetch.mockResolvedValue({
json: async () => ({
materializationHandled: true,
materializedVars: {
document:
'data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,Zm9v',
},
message: {
role: 'user',
content: JSON.stringify({
prompt: 'Summarize the uploaded planning document.',
document: 'doc payload',
notes: 'keep these notes',
}),
},
}),
ok: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(
targetProvider,
{
goal: 'initial goal',
document: 'stale document',
notes: 'stale notes',
},
undefined,
);
context.prompt = {
raw: 'Doc={{document}}; Notes={{notes}}',
label: 'test',
};
await provider.callApi('test prompt', context);
expect(getRenderedTargetPromptText(targetProvider)).toBe(
'Doc=data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,Zm9v; Notes=keep these notes',
);
});
it('should pass excludeTargetOutputFromAgenticAttackGeneration through config', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
excludeTargetOutputFromAgenticAttackGeneration: true,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider);
await provider.callApi('test prompt', context);
const lastCallBody = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(lastCallBody.excludeTargetOutputFromAgenticAttackGeneration).toBe(true);
});
it('should make correct API calls with message history', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
});
const targetProvider = createMockTargetProvider();
const prompt: Prompt = {
raw: 'test prompt',
label: 'test',
};
const context: CallApiContextParams = {
originalProvider: targetProvider,
vars: { goal: 'test goal' },
prompt,
};
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Max turns reached');
expect(mockFetch).toHaveBeenCalledWith(
getRemoteGenerationUrl(),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
body: expect.any(String),
}),
);
const bodyObj = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(bodyObj.purpose).toBeUndefined();
});
it('should stop when target ends conversation', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
});
const targetProvider = createMockTargetProvider(
'',
{},
{
conversationEnded: true,
conversationEndReason: 'thread_closed',
},
);
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Target ended conversation');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should handle grader integration and stop early on failure', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
});
const targetProvider = createMockTargetProvider('target response', {
total: 10,
prompt: 5,
completion: 5,
});
// Mock grader with pass:false (attack succeeded / jailbreak detected)
(mockGrader.getResult as any).mockResolvedValue({
grade: {
pass: false,
tokensUsed: { total: 5, prompt: 2, completion: 3 },
},
});
const testConfig = {
vars: {},
assert: [
{
type: 'contains',
value: 'expected content',
},
],
metadata: { pluginId: 'contains' },
} as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Grader failed');
expect(result.tokenUsage?.total).toBeGreaterThan(0);
});
it('should stringify non-string target provider responses', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const objectResponse = { foo: 'bar', baz: 123 };
const targetProvider = createMockTargetProvider(objectResponse);
const prompt: Prompt = {
raw: 'test prompt',
label: 'test',
};
const context: CallApiContextParams = {
originalProvider: targetProvider,
vars: { goal: 'test goal' },
prompt,
};
const result = await provider.callApi('test prompt', context);
const messages = result.metadata?.messages;
expect(messages[messages.length - 1].content).toBe(JSON.stringify(objectResponse));
});
it('should include purpose in API call when provided in test metadata', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const context: CallApiContextParams = {
originalProvider: targetProvider,
vars: { goal: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
vars: {},
metadata: {
purpose: 'test purpose',
},
} as AtomicTestCase,
};
await provider.callApi('test prompt', context);
const lastCallBody = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(lastCallBody.purpose).toBe('test purpose');
});
it('should handle undefined purpose in test metadata', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const context: CallApiContextParams = {
originalProvider: targetProvider,
vars: { goal: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
vars: {},
} as AtomicTestCase,
};
await provider.callApi('test prompt', context);
const lastCallBody = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
expect(lastCallBody.purpose).toBeUndefined();
});
describe('continueAfterSuccess functionality', () => {
beforeEach(() => {
// Reset mocks before each test
vi.clearAllMocks();
});
it('should stop on first successful attack when continueAfterSuccess is false (default)', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 5,
continueAfterSuccess: false,
});
const targetProvider = createMockTargetProvider('harmful response', {
total: 10,
prompt: 5,
completion: 5,
});
// Mock grader with pass:false on first attempt (indicating attack success)
(mockGrader.getResult as any).mockResolvedValue({
grade: {
pass: false,
tokensUsed: { total: 5, prompt: 2, completion: 3 },
},
});
const testConfig = {
vars: {},
assert: [{ type: 'contains', value: 'harmful' }],
metadata: { pluginId: 'contains' },
} as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Grader failed');
expect(result.metadata?.successfulAttacks).toHaveLength(1);
expect(result.metadata?.totalSuccessfulAttacks).toBe(1);
expect(result.metadata?.successfulAttacks?.[0]).toEqual({
turn: 0,
prompt: 'test response',
response: 'harmful response',
});
expect(mockFetch).toHaveBeenCalledTimes(1); // Only one turn
});
it('should continue and collect multiple successful attacks when continueAfterSuccess is true', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
continueAfterSuccess: true,
});
const targetProvider = createMockProvider();
targetProvider.callApi
.mockReset()
.mockResolvedValueOnce({
output: 'first harmful response',
tokenUsage: { total: 10, prompt: 5, completion: 5 },
})
.mockResolvedValueOnce({
output: 'second harmful response',
tokenUsage: { total: 12, prompt: 6, completion: 6 },
})
.mockResolvedValueOnce({
output: 'third response',
tokenUsage: { total: 8, prompt: 4, completion: 4 },
});
// Mock grader to fail on first two attempts (indicating success)
(mockGrader.getResult as any)
.mockResolvedValueOnce({
grade: {
pass: false,
tokensUsed: { total: 5, prompt: 2, completion: 3 },
},
})
.mockResolvedValueOnce({
grade: {
pass: false,
tokensUsed: { total: 6, prompt: 3, completion: 3 },
},
})
.mockResolvedValueOnce({
grade: {
pass: true,
tokensUsed: { total: 4, prompt: 2, completion: 2 },
},
});
const context: CallApiContextParams = {
originalProvider: targetProvider,
vars: { goal: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
vars: {},
assert: [{ type: 'contains', value: 'harmful' }],
metadata: { pluginId: 'contains' },
} as AtomicTestCase,
};
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Max turns reached');
expect(result.metadata?.successfulAttacks).toHaveLength(2);
expect(result.metadata?.totalSuccessfulAttacks).toBe(2);
expect(result.metadata?.successfulAttacks?.[0]).toEqual({
turn: 0,
prompt: 'test response',
response: 'first harmful response',
});
expect(result.metadata?.successfulAttacks?.[1]).toEqual({
turn: 1,
prompt: 'test response',
response: 'second harmful response',
});
expect(mockFetch).toHaveBeenCalledTimes(3); // All three turns
});
it('should have empty successfulAttacks array when no attacks succeed', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
continueAfterSuccess: true,
});
const targetProvider = createMockTargetProvider('safe response', {
total: 10,
prompt: 5,
completion: 5,
});
// Mock grader to always pass (indicating no successful attacks)
(mockGrader.getResult as any).mockResolvedValue({
grade: {
pass: true,
tokensUsed: { total: 5, prompt: 2, completion: 3 },
},
});
const context: CallApiContextParams = {
originalProvider: targetProvider,
vars: { goal: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
vars: {},
assert: [{ type: 'contains', value: 'harmful' }],
metadata: { pluginId: 'contains' },
} as AtomicTestCase,
};
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Max turns reached');
expect(result.metadata?.successfulAttacks).toHaveLength(0);
expect(result.metadata?.totalSuccessfulAttacks).toBe(0);
});
it('should initialize continueAfterSuccess to false by default', () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
});
expect(provider.config.continueAfterSuccess).toBe(false);
});
it('should respect continueAfterSuccess configuration option', () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
continueAfterSuccess: true,
});
expect(provider.config.continueAfterSuccess).toBe(true);
});
it('should handle mixed success and failure cases with continueAfterSuccess', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 4,
continueAfterSuccess: true,
});
const targetProvider = createMockProvider();
targetProvider.callApi
.mockReset()
.mockResolvedValueOnce({ output: 'safe response 1', tokenUsage: {} })
.mockResolvedValueOnce({ output: 'harmful response 1', tokenUsage: {} })
.mockResolvedValueOnce({ output: 'safe response 2', tokenUsage: {} })
.mockResolvedValueOnce({ output: 'harmful response 2', tokenUsage: {} });
// Mock grader: pass, fail, pass, fail
(mockGrader.getResult as any)
.mockResolvedValueOnce({ grade: { pass: true } })
.mockResolvedValueOnce({ grade: { pass: false } })
.mockResolvedValueOnce({ grade: { pass: true } })
.mockResolvedValueOnce({ grade: { pass: false } });
const context: CallApiContextParams = {
originalProvider: targetProvider,
vars: { goal: 'test goal' },
prompt: { raw: 'test prompt', label: 'test' },
test: {
vars: {},
assert: [{ type: 'contains', value: 'harmful' }],
metadata: { pluginId: 'contains' },
} as AtomicTestCase,
};
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.stopReason).toBe('Max turns reached');
expect(result.metadata?.successfulAttacks).toHaveLength(2);
expect(result.metadata?.totalSuccessfulAttacks).toBe(2);
expect(result.metadata?.successfulAttacks?.[0].response).toBe('harmful response 1');
expect(result.metadata?.successfulAttacks?.[1].response).toBe('harmful response 2');
});
});
it('should pass additionalRubric to grader when goal is provided', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
// Mock grader to capture the additionalRubric parameter
let capturedAdditionalRubric: string | undefined;
(mockGrader.getResult as any).mockImplementation(async function (
_prompt: string,
_output: string,
_test: any,
_provider: any,
_value: any,
additionalRubric?: string,
) {
capturedAdditionalRubric = additionalRubric;
return {
grade: {
pass: false,
score: 0,
reason: 'Test jailbreak detected',
},
};
});
const testConfig = {
vars: {},
assert: [
{
type: 'contains',
value: 'expected content',
},
],
metadata: {
pluginId: 'contains',
goal: 'Extract sensitive information',
},
} as AtomicTestCase;
const context = createMockContext(
targetProvider,
{ goal: 'Extract sensitive information' },
testConfig,
);
await provider.callApi('test prompt', context);
// Verify that additionalRubric was generated and passed to grader
expect(capturedAdditionalRubric).toBeDefined();
expect(capturedAdditionalRubric).toContain('Extract sensitive information');
expect(capturedAdditionalRubric).toContain('ADDITIONAL EVALUATION CRITERIA');
});
it('should store grader result in metadata when grader fails', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const mockGraderResult = {
pass: false,
score: 0,
reason: 'Jailbreak successful - sensitive info extracted',
tokensUsed: { total: 10, prompt: 5, completion: 5 },
};
// Mock grader to fail (indicating jailbreak success)
(mockGrader.getResult as any).mockResolvedValue({
grade: mockGraderResult,
});
const testConfig = {
vars: {},
assert: [
{
type: 'contains',
value: 'expected content',
},
],
metadata: { pluginId: 'contains' },
} as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
// Verify storedGraderResult is included in metadata
expect(result.metadata?.storedGraderResult).toMatchObject(mockGraderResult);
expect(result.metadata?.storedGraderResult?.assertion).toBeDefined();
expect(result.metadata?.stopReason).toBe('Grader failed');
});
it('should store rendered rubric in assertion.value when grader returns rubric', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const mockRenderedRubric = `
<system_purpose>Medical chatbot</system_purpose>
<policy>Only return NAICS codes if 100% certain</policy>
Evaluate if the output violates the given policy...
`;
const mockGraderResult = {
pass: false,
score: 0,
reason: 'Policy violation detected',
};
// Mock grader to return both grade and rubric
(mockGrader.getResult as any).mockResolvedValue({
grade: mockGraderResult,
rubric: mockRenderedRubric,
});
const testConfig = {
vars: {},
assert: [
{
type: 'promptfoo:redteam:policy',
metric: 'PolicyViolation:401a805692b3',
},
],
metadata: { pluginId: 'policy' },
} as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
// Verify storedGraderResult includes the rendered rubric in assertion.value
expect(result.metadata?.storedGraderResult).toBeDefined();
expect(result.metadata?.storedGraderResult?.assertion).toBeDefined();
expect(result.metadata?.storedGraderResult?.assertion?.value).toBe(mockRenderedRubric);
expect(result.metadata?.storedGraderResult?.assertion?.type).toBe('promptfoo:redteam:policy');
expect(result.metadata?.stopReason).toBe('Grader failed');
});
it('should store rubric with grade.assertion when grader returns assertion with existing value', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const mockRenderedRubric = '<rendered_rubric>Evaluation criteria</rendered_rubric>';
const mockGraderResult = {
pass: false,
score: 0,
reason: 'Test failed',
assertion: {
type: 'promptfoo:redteam:policy' as const,
metric: 'PolicyViolation:test',
value: 'old value',
},
};
// Mock grader returning grade with assertion
(mockGrader.getResult as any).mockResolvedValue({
grade: mockGraderResult,
rubric: mockRenderedRubric,
});
const testConfig = {
vars: {},
assert: [
{
type: 'promptfoo:redteam:policy',
metric: 'PolicyViolation:test',
},
],
metadata: { pluginId: 'policy' },
} as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
// Verify that the rubric overrides the old value
expect(result.metadata?.storedGraderResult?.assertion?.value).toBe(mockRenderedRubric);
expect(result.metadata?.storedGraderResult?.assertion?.type).toBe('promptfoo:redteam:policy');
expect(result.metadata?.storedGraderResult?.assertion?.metric).toBe('PolicyViolation:test');
});
it('should use assertion from test config when grade.assertion is undefined', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const mockRenderedRubric = '<rubric>Test rubric</rubric>';
const mockGraderResult = {
pass: false,
score: 0,
reason: 'Test failed',
// No assertion field
};
(mockGrader.getResult as any).mockResolvedValue({
grade: mockGraderResult,
rubric: mockRenderedRubric,
});
const testConfig = {
vars: {},
assert: [
{
type: 'promptfoo:redteam:harmful',
metric: 'Harmful',
},
],
metadata: { pluginId: 'harmful' },
} as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
// Should use assertion from test config with rubric as value
expect(result.metadata?.storedGraderResult?.assertion?.value).toBe(mockRenderedRubric);
expect(result.metadata?.storedGraderResult?.assertion?.type).toBe('promptfoo:redteam:harmful');
expect(result.metadata?.storedGraderResult?.assertion?.metric).toBe('Harmful');
});
it('should not store assertion when assertToUse is AssertionSet', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const mockRenderedRubric = '<rubric>Test rubric</rubric>';
const mockGraderResult = {
pass: false,
score: 0,
reason: 'Test failed',
};
(mockGrader.getResult as any).mockResolvedValue({
grade: mockGraderResult,
rubric: mockRenderedRubric,
});
const testConfig = {
vars: {},
assert: [
{
type: 'assert-set' as const,
assert: [
{ type: 'contains', value: 'test' },
{ type: 'contains', value: 'another' },
],
},
],
metadata: { pluginId: 'policy' },
} as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
// Should not create assertion from AssertionSet
expect(result.metadata?.storedGraderResult?.assertion).toBeUndefined();
expect(result.metadata?.storedGraderResult?.pass).toBe(false);
expect(result.metadata?.storedGraderResult?.score).toBe(0);
});
it('should store grader result even when continueAfterSuccess is true', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
continueAfterSuccess: true,
});
const targetProvider = createMockTargetProvider();
const firstGraderResult = {
pass: false,
score: 0,
reason: 'Jailbreak detected',
};
const secondGraderResult = {
pass: true,
score: 1,
reason: 'No jailbreak detected',
};
// Mock grader to fail on first turn, pass on second turn
(mockGrader.getResult as any)
.mockResolvedValueOnce({
grade: firstGraderResult,
})
.mockResolvedValueOnce({
grade: secondGraderResult,
});
// Mock remote generation API for second turn
mockFetch.mockImplementationOnce(async function () {
return {
json: async () => ({
message: { role: 'assistant', content: 'attack prompt' },
}),
ok: true,
};
});
const testConfig = {
vars: {},
assert: [
{
type: 'contains',
value: 'expected content',
},
],
metadata: { pluginId: 'contains' },
} as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
// Should continue to max turns and store the LAST grader result
expect(result.metadata?.storedGraderResult).toMatchObject(secondGraderResult);
expect(result.metadata?.storedGraderResult?.assertion).toBeDefined();
expect(result.metadata?.stopReason).toBe('Max turns reached');
expect(result.metadata?.successfulAttacks).toHaveLength(1);
// The successful attack should be from the first turn
expect(result.metadata?.successfulAttacks?.[0]).toMatchObject({
turn: 0,
prompt: expect.any(String),
response: expect.any(String),
});
});
it('should grade image-only target responses', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider('', undefined, {
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
});
const graderResult = {
pass: false,
score: 0,
reason: 'Visual policy failure detected',
};
(mockGrader.getResult as any).mockResolvedValue({
grade: graderResult,
rubric: 'rendered rubric',
});
const testConfig = {
vars: {},
assert: [{ type: 'harmful:test' }],
metadata: { pluginId: 'harmful:test', goal: 'test goal' },
} as unknown as AtomicTestCase;
const context = createMockContext(targetProvider, { goal: 'test goal' }, testConfig);
const result = await provider.callApi('test prompt', context);
expect(mockGrader.getResult).toHaveBeenCalledOnce();
expect(mockGrader.getResult.mock.calls[0][1]).toBe(
'[Image output attached. Inspect the attached image directly for visual grading.]',
);
expect(mockGrader.getResult.mock.calls[0][7]).toMatchObject({
providerResponse: {
images: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
},
imageOutputs: [{ data: 'data:image/png;base64,abc123', mimeType: 'image/png' }],
});
expect(result.metadata?.storedGraderResult).toMatchObject(graderResult);
expect(result.metadata?.stopReason).toBe('Grader failed');
});
describe('Token Counting', () => {
beforeEach(async () => {
// Reset TokenUsageTracker between tests to ensure clean state
const { TokenUsageTracker } = await import('../../../src/util/tokenUsage');
TokenUsageTracker.getInstance().resetAllUsage();
});
it('should correctly track token usage from target provider', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider('target response', {
total: 100,
prompt: 60,
completion: 40,
numRequests: 1,
});
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
// Verify that target token usage is accumulated
expect(result.tokenUsage?.total).toBe(100);
expect(result.tokenUsage?.prompt).toBe(60);
expect(result.tokenUsage?.completion).toBe(40);
expect(result.tokenUsage?.numRequests).toBe(1);
});
it('should accumulate token usage across multiple turns', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
});
const targetProvider = createMockProvider();
targetProvider.callApi
.mockReset()
.mockResolvedValueOnce({
output: 'response 1',
tokenUsage: { total: 100, prompt: 60, completion: 40, numRequests: 1 },
cached: false,
})
.mockResolvedValueOnce({
output: 'response 2',
tokenUsage: { total: 150, prompt: 90, completion: 60, numRequests: 1 },
cached: false,
})
.mockResolvedValueOnce({
output: 'response 3',
tokenUsage: { total: 200, prompt: 120, completion: 80, numRequests: 1 },
cached: false,
});
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
// Verify accumulated token usage from all target calls
expect(result.tokenUsage?.total).toBe(450); // 100 + 150 + 200
expect(result.tokenUsage?.prompt).toBe(270); // 60 + 90 + 120
expect(result.tokenUsage?.completion).toBe(180); // 40 + 60 + 80
expect(result.tokenUsage?.numRequests).toBe(3);
});
it('should handle missing token usage from target responses', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
});
const targetProvider = createMockProvider();
targetProvider.callApi
.mockReset()
.mockResolvedValueOnce({
output: 'response with tokens',
tokenUsage: { total: 100, prompt: 60, completion: 40, numRequests: 1 },
cached: false,
})
.mockResolvedValueOnce({
output: 'response without tokens',
// No tokenUsage provided
cached: false,
})
.mockResolvedValueOnce({
output: 'another response with tokens',
tokenUsage: { total: 200, prompt: 120, completion: 80 }, // numRequests missing
cached: false,
});
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
// Token usage should accumulate correctly even with missing data
expect(result.tokenUsage?.total).toBe(300); // 100 + 0 + 200
expect(result.tokenUsage?.prompt).toBe(180); // 60 + 0 + 120
expect(result.tokenUsage?.completion).toBe(120); // 40 + 0 + 80
expect(result.tokenUsage?.numRequests).toBe(3); // All calls counted
});
it('should handle error responses without affecting token counts', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
});
const targetProvider = createMockProvider();
targetProvider.callApi
.mockReset()
.mockResolvedValueOnce({
output: 'successful response',
tokenUsage: { total: 100, prompt: 60, completion: 40, numRequests: 1 },
cached: false,
})
.mockRejectedValueOnce(new Error('Target provider failed'));
const context = createMockContext(targetProvider);
let result;
try {
result = await provider.callApi('test prompt', context);
} catch (error) {
// GOAT provider throws errors on target failures, unlike iterative which continues
expect(error).toBeDefined();
return;
}
// If we get here, the provider handled the error gracefully
expect(result.tokenUsage?.total).toBeGreaterThan(0);
});
it('should handle zero token counts correctly', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
});
const targetProvider = createMockProvider();
targetProvider.callApi
.mockReset()
.mockResolvedValueOnce({
output: 'response with zero tokens',
tokenUsage: { total: 0, prompt: 0, completion: 0, numRequests: 1 },
cached: false,
})
.mockResolvedValueOnce({
output: 'response with normal tokens',
tokenUsage: { total: 100, prompt: 60, completion: 40, numRequests: 1 },
cached: false,
});
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
// Should handle zero counts correctly: 0 + 100 = 100
expect(result.tokenUsage?.total).toBe(100);
expect(result.tokenUsage?.prompt).toBe(60);
expect(result.tokenUsage?.completion).toBe(40);
expect(result.tokenUsage?.numRequests).toBe(2);
});
it('should accumulate token usage with unblocking responses', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
});
const targetProvider = createMockProvider();
// First call (normal attack), second call (next attack)
targetProvider.callApi
.mockReset()
.mockResolvedValueOnce({
output: 'first response',
tokenUsage: { total: 50, prompt: 30, completion: 20, numRequests: 1 },
cached: false,
})
.mockResolvedValueOnce({
output: 'second response',
tokenUsage: { total: 75, prompt: 45, completion: 30, numRequests: 1 },
cached: false,
});
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
// Should accumulate tokens from all calls
expect(result.tokenUsage?.total).toBe(125); // 50 + 75
expect(result.tokenUsage?.prompt).toBe(75); // 30 + 45
expect(result.tokenUsage?.completion).toBe(50); // 20 + 30
expect(result.tokenUsage?.numRequests).toBe(2);
});
});
describe('Abort Signal Handling', () => {
it('should re-throw AbortError from fetchWithProxy and not swallow it', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
});
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
// Mock fetch to throw AbortError
mockFetch.mockRejectedValueOnce(abortError);
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider);
await expect(provider.callApi('test prompt', context)).rejects.toThrow(
'The operation was aborted',
);
});
it('should pass options with abortSignal to target provider callApi', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider);
const abortController = new AbortController();
const options = { abortSignal: abortController.signal };
await provider.callApi('test prompt', context, options);
// Verify that callApi was called with the options
expect(targetProvider.callApi).toHaveBeenCalledWith(
expect.any(String),
expect.any(Object),
options,
);
});
it('should swallow non-AbortError exceptions and continue the loop', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
});
const regularError = new Error('Network error');
// First turn fails with non-AbortError, second turn succeeds
mockFetch.mockRejectedValueOnce(regularError).mockImplementationOnce(async () => ({
json: async () => ({
message: { role: 'assistant', content: 'test response' },
}),
ok: true,
}));
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider);
// Should NOT throw - should continue to next turn
const result = await provider.callApi('test prompt', context);
// Should complete without throwing
expect(result.metadata?.stopReason).toBe('Max turns reached');
});
});
describe('perTurnLayers configuration', () => {
it('should initialize with _perTurnLayers config', () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
_perTurnLayers: ['audio', 'base64'],
});
expect(provider.config._perTurnLayers).toEqual(['audio', 'base64']);
});
it('should accept perTurnLayers with object config', () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 3,
_perTurnLayers: [{ id: 'audio', config: { voice: 'alloy' } }, 'base64'],
});
expect(provider.config._perTurnLayers).toHaveLength(2);
expect(provider.config._perTurnLayers![0]).toEqual({
id: 'audio',
config: { voice: 'alloy' },
});
});
it('should default perTurnLayers to empty array when not provided', () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
});
expect(provider.config._perTurnLayers).toBeUndefined();
});
});
describe('redteamHistory with audio/image data', () => {
it('should include redteamHistory in metadata', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 2,
});
const targetProvider = createMockTargetProvider('target response');
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
// redteamHistory should be present in metadata
expect(result.metadata?.redteamHistory).toBeDefined();
expect(Array.isArray(result.metadata?.redteamHistory)).toBe(true);
});
it('should capture prompt and output in redteamHistory entries', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider('target response text');
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
const history = result.metadata?.redteamHistory;
if (history && history.length > 0) {
const entry = history[0];
expect(entry).toHaveProperty('prompt');
expect(entry).toHaveProperty('output');
expect(entry.output).toBe('target response text');
}
});
it('should have optional promptAudio and promptImage fields in redteamHistory', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider('response');
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
const history = result.metadata?.redteamHistory;
if (history && history.length > 0) {
const entry = history[0];
// These fields should be undefined when no perTurnLayers are configured
expect(entry.promptAudio).toBeUndefined();
expect(entry.promptImage).toBeUndefined();
}
});
it('should capture outputAudio when target returns audio data', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider(
'response with audio',
{},
{
audio: { data: 'base64audiodata', format: 'mp3' },
},
);
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
const history = result.metadata?.redteamHistory;
if (history && history.length > 0) {
const entry = history[0];
expect(entry.outputAudio).toBeDefined();
expect(entry.outputAudio?.data).toBe('base64audiodata');
expect(entry.outputAudio?.format).toBe('mp3');
}
});
});
describe('sessionId handling', () => {
it('should include sessionId from context.vars in metadata', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider('response');
const context = createMockContext(targetProvider, {
goal: 'test goal',
sessionId: 'test-session-123',
});
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.sessionId).toBe('test-session-123');
});
it('should include sessionId from context vars when stateful is false', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider();
const context = createMockContext(targetProvider, {
goal: 'test goal',
sessionId: 'context-session-id',
});
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.sessionId).toBe('context-session-id');
});
it('should include sessionId from target response when stateful is true', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
stateful: true,
});
const targetProvider = createMockTargetProvider(
'target response',
{},
{
sessionId: 'response-session-id',
},
);
const context = createMockContext(targetProvider);
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.sessionId).toBe('response-session-id');
});
it('should handle missing sessionId gracefully', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider('response');
const context = createMockContext(targetProvider, { goal: 'test goal' });
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.sessionId).toBeUndefined();
});
it('should stringify non-string sessionId', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider('response');
const context = createMockContext(targetProvider, {
goal: 'test goal',
sessionId: 123 as any, // Non-string sessionId
});
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.sessionId).toBe('123');
});
it('should include sessionId in metadata along with other metadata fields', async () => {
const provider = new RedteamGoatProvider({
injectVar: 'goal',
maxTurns: 1,
});
const targetProvider = createMockTargetProvider('response');
const testConfig = {
vars: {},
assert: [{ type: 'contains', value: 'harmful' }],
metadata: { pluginId: 'contains' },
} as AtomicTestCase;
const context = createMockContext(
targetProvider,
{ goal: 'test goal', sessionId: 'session-with-metadata' },
testConfig,
);
const result = await provider.callApi('test prompt', context);
expect(result.metadata?.sessionId).toBe('session-with-metadata');
expect(result.metadata?.stopReason).toBeDefined();
expect(result.metadata?.messages).toBeDefined();
});
});
});