import { jest } from '@jest/globals';
import { z } from 'zod';
/**
* Tests for Claude Code native structured output support (v2.2.0+)
*
* ai-sdk-provider-claude-code v2.2.0 introduced native structured outputs via
* the Claude Agent SDK's outputFormat option. When using generateObject() with
* a schema, the SDK now guarantees schema-compliant JSON through constrained decoding.
*
* Key behaviors tested:
* 1. Schema is passed correctly to the SDK
* 2. mode: 'json' is used (which enables native outputFormat in the SDK)
* 3. SDK error handling for schema validation failures
*/
// Mock generateObject from 'ai' SDK
const mockGenerateObject = jest.fn();
jest.unstable_mockModule('ai', () => ({
generateObject: mockGenerateObject,
generateText: jest.fn(),
streamText: jest.fn(),
streamObject: jest.fn(),
zodSchema: jest.fn((schema) => schema),
JSONParseError: class JSONParseError extends Error {
constructor(message, text) {
super(message);
this.text = text;
}
},
NoObjectGeneratedError: class NoObjectGeneratedError extends Error {
static isInstance(error) {
return error instanceof NoObjectGeneratedError;
}
}
}));
// Mock jsonrepair
jest.unstable_mockModule('jsonrepair', () => ({
jsonrepair: jest.fn((text) => text)
}));
// Mock the ai-sdk-provider-claude-code package
jest.unstable_mockModule('ai-sdk-provider-claude-code', () => ({
createClaudeCode: jest.fn(() => {
const provider = (modelId) => ({
id: modelId,
specificationVersion: 'v1',
provider: 'claude-code',
modelId
});
provider.languageModel = provider;
provider.chat = provider;
return provider;
})
}));
// Mock config getters
jest.unstable_mockModule('../../../scripts/modules/config-manager.js', () => ({
getClaudeCodeSettingsForCommand: jest.fn(() => ({})),
getSupportedModelsForProvider: jest.fn(() => ['opus', 'sonnet', 'haiku']),
getDebugFlag: jest.fn(() => false),
getLogLevel: jest.fn(() => 'info'),
isProxyEnabled: jest.fn(() => false),
getAnonymousTelemetryEnabled: jest.fn(() => true),
setSuppressConfigWarnings: jest.fn(),
isConfigWarningSuppressed: jest.fn(() => false)
}));
// Mock utils
jest.unstable_mockModule('../../../scripts/modules/utils.js', () => ({
log: jest.fn(),
findProjectRoot: jest.fn(() => '/test/project'),
resolveEnvVariable: jest.fn((key) => process.env[key])
}));
// Import after mocking
const { ClaudeCodeProvider } = await import(
'../../../src/ai-providers/claude-code.js'
);
describe('ClaudeCodeProvider structured outputs (v2.2.0+)', () => {
let provider;
beforeEach(() => {
provider = new ClaudeCodeProvider();
jest.clearAllMocks();
});
describe('needsExplicitJsonSchema flag', () => {
it('should have needsExplicitJsonSchema set to true', () => {
// This flag triggers mode: 'json' in base-provider.js generateObject()
// which in turn enables the SDK's native outputFormat with constrained decoding
expect(provider.needsExplicitJsonSchema).toBe(true);
});
it('should not support temperature parameter', () => {
// Claude Code SDK doesn't support temperature
expect(provider.supportsTemperature).toBe(false);
});
});
describe('generateObject with schema', () => {
const testSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email()
});
const testMessages = [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Generate a user profile' }
];
beforeEach(() => {
// Mock successful generateObject response
mockGenerateObject.mockResolvedValue({
object: { name: 'Test User', age: 25, email: 'test@example.com' },
usage: {
promptTokens: 100,
completionTokens: 50,
totalTokens: 150
}
});
});
it('should pass schema to generateObject call', async () => {
await provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: testMessages,
schema: testSchema,
objectName: 'user_profile'
});
expect(mockGenerateObject).toHaveBeenCalledWith(
expect.objectContaining({
schema: testSchema
})
);
});
it('should use json mode for Claude Code (enables native outputFormat)', async () => {
await provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: testMessages,
schema: testSchema,
objectName: 'user_profile'
});
// mode: 'json' is set when needsExplicitJsonSchema is true
// This triggers the SDK to use outputFormat: { type: 'json_schema', schema: ... }
expect(mockGenerateObject).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'json'
})
);
});
it('should pass schemaName for better SDK context', async () => {
await provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: testMessages,
schema: testSchema,
objectName: 'user_profile'
});
expect(mockGenerateObject).toHaveBeenCalledWith(
expect.objectContaining({
schemaName: 'user_profile'
})
);
});
it('should return structured object from SDK', async () => {
const result = await provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: testMessages,
schema: testSchema,
objectName: 'user_profile'
});
expect(result.object).toEqual({
name: 'Test User',
age: 25,
email: 'test@example.com'
});
});
it('should return usage information', async () => {
const result = await provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: testMessages,
schema: testSchema,
objectName: 'user_profile'
});
expect(result.usage).toEqual({
inputTokens: 100,
outputTokens: 50,
totalTokens: 150
});
});
});
describe('complex schemas', () => {
it('should handle nested object schemas', async () => {
const complexSchema = z.object({
tasks: z.array(
z.object({
id: z.number(),
title: z.string(),
subtasks: z.array(
z.object({
id: z.number(),
title: z.string()
})
)
})
)
});
mockGenerateObject.mockResolvedValue({
object: {
tasks: [
{
id: 1,
title: 'Main Task',
subtasks: [{ id: 1, title: 'Subtask 1' }]
}
]
},
usage: { promptTokens: 50, completionTokens: 30, totalTokens: 80 }
});
const result = await provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: [{ role: 'user', content: 'Generate tasks' }],
schema: complexSchema,
objectName: 'task_list'
});
expect(result.object.tasks).toHaveLength(1);
expect(result.object.tasks[0].subtasks).toHaveLength(1);
});
it('should handle enum schemas (like task priority)', async () => {
const prioritySchema = z.object({
priority: z.enum(['high', 'medium', 'low']),
title: z.string()
});
mockGenerateObject.mockResolvedValue({
object: { priority: 'high', title: 'Important Task' },
usage: { promptTokens: 30, completionTokens: 20, totalTokens: 50 }
});
const result = await provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: [{ role: 'user', content: 'Create a task' }],
schema: prioritySchema,
objectName: 'task'
});
expect(result.object.priority).toBe('high');
});
});
describe('error handling', () => {
it('should throw error when schema is missing', async () => {
await expect(
provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: [{ role: 'user', content: 'test' }],
objectName: 'test'
// schema is missing
})
).rejects.toThrow('Schema is required');
});
it('should throw error when objectName is missing', async () => {
await expect(
provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: [{ role: 'user', content: 'test' }],
schema: z.object({ name: z.string() })
// objectName is missing
})
).rejects.toThrow('Object name is required');
});
it('should handle SDK errors gracefully', async () => {
mockGenerateObject.mockRejectedValue(
new Error('SDK error: Failed to generate')
);
await expect(
provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: [{ role: 'user', content: 'test' }],
schema: z.object({ name: z.string() }),
objectName: 'test'
})
).rejects.toThrow();
});
});
describe('v2.2.0 native structured output benefits', () => {
/**
* These tests document the expected behavior with v2.2.0's native schema support.
* The SDK now handles schema validation internally through constrained decoding,
* so the jsonrepair fallback in base-provider.js should rarely be triggered
* for Claude Code operations.
*/
it('should work with Task Master command schemas', async () => {
// This simulates the expand-task schema pattern
const expandTaskSchema = z.object({
subtasks: z.array(
z.object({
id: z.number().int().positive(),
title: z.string().min(1),
description: z.string().min(1),
dependencies: z.array(z.number().int()),
details: z.string(),
testStrategy: z.string()
})
)
});
mockGenerateObject.mockResolvedValue({
object: {
subtasks: [
{
id: 1,
title: 'Implement feature X',
description: 'Description for feature X',
dependencies: [],
details: 'Implementation details',
testStrategy: 'Unit tests for feature X'
}
]
},
usage: { promptTokens: 200, completionTokens: 100, totalTokens: 300 }
});
const result = await provider.generateObject({
apiKey: 'test-key',
modelId: 'sonnet',
messages: [{ role: 'user', content: 'Expand task into subtasks' }],
schema: expandTaskSchema,
objectName: 'subtasks'
});
expect(result.object.subtasks).toHaveLength(1);
expect(result.object.subtasks[0].id).toBe(1);
expect(result.object.subtasks[0].title).toBe('Implement feature X');
});
});
});