// src/graphs/__tests__/structured-output.test.ts import { StructuredOutputRefusalError, StructuredOutputTruncatedError } from '@/types/graph'; import { prepareSchemaForProvider } from '@/schemas/validate'; import { Providers } from '@/common'; /** * Unit tests for the structured output logic in Graph.ts. * These test individual components and error classes without instantiating the full Graph. * Integration tests with real models are in structured-output.integration.test.ts. */ describe('StructuredOutputRefusalError', () => { it('creates error with refusal text', () => { const error = new StructuredOutputRefusalError('I cannot produce that output'); expect(error.name).toBe('StructuredOutputRefusalError'); expect(error.refusalText).toBe('I cannot produce that output'); expect(error.message).toContain('Model refused'); expect(error.message).toContain('I cannot produce that output'); expect(error instanceof Error).toBe(true); }); }); describe('StructuredOutputTruncatedError', () => { it('creates error with stop reason', () => { const error = new StructuredOutputTruncatedError('max_tokens'); expect(error.name).toBe('StructuredOutputTruncatedError'); expect(error.stopReason).toBe('max_tokens'); expect(error.message).toContain('truncated'); expect(error.message).toContain('max_tokens'); expect(error.message).toContain('Increase max_tokens'); expect(error instanceof Error).toBe(true); }); it('works with OpenAI-style length stop reason', () => { const error = new StructuredOutputTruncatedError('length'); expect(error.stopReason).toBe('length'); expect(error.message).toContain('length'); }); }); describe('Error handling in structured output', () => { it('StructuredOutputRefusalError is catchable as Error', () => { try { throw new StructuredOutputRefusalError('safety refusal'); } catch (e) { expect(e instanceof Error).toBe(true); expect(e instanceof StructuredOutputRefusalError).toBe(true); } }); it('StructuredOutputTruncatedError is catchable as Error', () => { try { throw new StructuredOutputTruncatedError('max_tokens'); } catch (e) { expect(e instanceof Error).toBe(true); expect(e instanceof StructuredOutputTruncatedError).toBe(true); } }); it('errors are distinguishable from generic errors', () => { const refusal = new StructuredOutputRefusalError('nope'); const truncated = new StructuredOutputTruncatedError('max_tokens'); const generic = new Error('generic'); expect(refusal instanceof StructuredOutputTruncatedError).toBe(false); expect(truncated instanceof StructuredOutputRefusalError).toBe(false); expect(generic instanceof StructuredOutputRefusalError).toBe(false); expect(generic instanceof StructuredOutputTruncatedError).toBe(false); }); }); describe('Schema preparation for native structured output', () => { const testSchema = { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number', minimum: 0, maximum: 150 }, tags: { type: 'array', items: { type: 'string' }, minItems: 1 }, address: { type: 'object', properties: { street: { type: 'string', maxLength: 200 }, zip: { type: 'string', pattern: '^\\d{5}$' }, }, }, }, required: ['name'], }; it('prepares schema for Anthropic native mode', () => { const { schema, warnings } = prepareSchemaForProvider( testSchema, Providers.ANTHROPIC, ); // Root level expect(schema.additionalProperties).toBe(false); expect(schema.required).toEqual( expect.arrayContaining(['name', 'age', 'tags', 'address']), ); // Numeric constraints stripped const ageProp = (schema.properties as Record>).age; expect(ageProp.minimum).toBeUndefined(); expect(ageProp.maximum).toBeUndefined(); expect(ageProp.description).toContain('minimum'); expect(ageProp.description).toContain('maximum'); // Array constraints stripped const tagsProp = (schema.properties as Record>).tags; expect(tagsProp.minItems).toBeUndefined(); // Nested object prepared const addressProp = (schema.properties as Record>).address; expect(addressProp.additionalProperties).toBe(false); expect(addressProp.required).toEqual( expect.arrayContaining(['street', 'zip']), ); // String constraints stripped const streetProp = (addressProp.properties as Record>).street; expect(streetProp.maxLength).toBeUndefined(); const zipProp = (addressProp.properties as Record>).zip; expect(zipProp.pattern).toBeUndefined(); // Should have multiple warnings expect(warnings.length).toBeGreaterThan(0); }); it('prepares schema for OpenAI native mode', () => { const { schema } = prepareSchemaForProvider( testSchema, Providers.OPENAI, ); // Same constraints as Anthropic expect(schema.additionalProperties).toBe(false); const ageProp = (schema.properties as Record>).age; expect(ageProp.minimum).toBeUndefined(); }); it('prepares schema for Bedrock', () => { const { schema } = prepareSchemaForProvider( testSchema, Providers.BEDROCK, ); // Same treatment expect(schema.additionalProperties).toBe(false); const ageProp = (schema.properties as Record>).age; expect(ageProp.minimum).toBeUndefined(); }); }); describe('Stream aggregation behavior', () => { it('synthetic tool call names are correctly identified', () => { // These names should be filtered by stream.ts const structuredOutputToolNames = ['response', 'structuredresponse', 'structured_response']; for (const name of structuredOutputToolNames) { expect( name === 'response' || name === 'structuredresponse' || name === 'structured_response', ).toBe(true); } }); it('regular tool names are not confused with structured output', () => { const regularToolNames = ['web_search', 'calculator', 'my_response_handler']; for (const name of regularToolNames) { const lower = name.toLowerCase(); const isStructured = lower === 'response' || lower === 'structuredresponse' || lower === 'structured_response'; expect(isStructured).toBe(false); } }); });