diff --git a/src/format/request-converter.js b/src/format/request-converter.js index 3e97406..fe8b5c8 100644 --- a/src/format/request-converter.js +++ b/src/format/request-converter.js @@ -210,10 +210,11 @@ export function convertAnthropicToGoogle(anthropicRequest) { // Sanitize schema for general compatibility let parameters = sanitizeSchema(schema); - // For Gemini models, apply additional cleaning for VALIDATED mode - if (isGeminiModel) { - parameters = cleanSchemaForGemini(parameters); - } + // Apply Google-format cleaning for ALL models since they all go through + // Cloud Code API which validates schemas using Google's protobuf format. + // This fixes issue #82: /compact command fails with schema transformation error + // "Proto field is not repeating, cannot start list" for Claude models. + parameters = cleanSchemaForGemini(parameters); return { name: String(name).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64), diff --git a/src/format/schema-sanitizer.js b/src/format/schema-sanitizer.js index efe9a0d..5cb9110 100644 --- a/src/format/schema-sanitizer.js +++ b/src/format/schema-sanitizer.js @@ -564,6 +564,27 @@ export function sanitizeSchema(schema) { return sanitized; } +/** + * Convert JSON Schema type names to Google's Protobuf-style uppercase type names. + * Google's Generative AI API expects uppercase types: STRING, OBJECT, ARRAY, etc. + * + * @param {string} type - JSON Schema type name (lowercase) + * @returns {string} Google-format type name (uppercase) + */ +function toGoogleType(type) { + if (!type || typeof type !== 'string') return type; + const typeMap = { + 'string': 'STRING', + 'number': 'NUMBER', + 'integer': 'INTEGER', + 'boolean': 'BOOLEAN', + 'array': 'ARRAY', + 'object': 'OBJECT', + 'null': 'STRING' // Fallback for null type + }; + return typeMap[type.toLowerCase()] || type.toUpperCase(); +} + /** * Cleans JSON schema for Gemini API compatibility. * Uses a multi-phase pipeline matching opencode-antigravity-auth approach. @@ -642,5 +663,11 @@ export function cleanSchemaForGemini(schema) { } } + // Phase 5: Convert type to Google's uppercase format (STRING, OBJECT, ARRAY, etc.) + // Only convert at current level - nested types already converted by recursive cleanSchemaForGemini calls + if (result.type && typeof result.type === 'string') { + result.type = toGoogleType(result.type); + } + return result; } diff --git a/tests/test-schema-sanitizer.cjs b/tests/test-schema-sanitizer.cjs new file mode 100644 index 0000000..44b957c --- /dev/null +++ b/tests/test-schema-sanitizer.cjs @@ -0,0 +1,269 @@ +/** + * Test Schema Sanitizer - Tests for schema transformation for Google API + * + * Verifies that complex nested array schemas are properly converted to + * Google's protobuf format (uppercase type names) to fix issue #82: + * "Proto field is not repeating, cannot start list" + */ + +// Import the schema sanitizer functions +const path = require('path'); + +// Since we're in CommonJS and the module is ESM, we need to use dynamic import +async function runTests() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ SCHEMA SANITIZER TEST SUITE ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Dynamic import for ESM module + const { sanitizeSchema, cleanSchemaForGemini } = await import('../src/format/schema-sanitizer.js'); + + let passed = 0; + let failed = 0; + + function test(name, fn) { + try { + fn(); + console.log(`✓ ${name}`); + passed++; + } catch (e) { + console.log(`✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } + } + + function assertEqual(actual, expected, message = '') { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${message}\nExpected: ${JSON.stringify(expected, null, 2)}\nActual: ${JSON.stringify(actual, null, 2)}`); + } + } + + function assertIncludes(actual, substring, message = '') { + if (!JSON.stringify(actual).includes(substring)) { + throw new Error(`${message}\nExpected to include: ${substring}\nActual: ${JSON.stringify(actual, null, 2)}`); + } + } + + // Test 1: Basic type conversion to uppercase + test('Basic type conversion to uppercase', () => { + const schema = { type: 'string', description: 'A test string' }; + const result = cleanSchemaForGemini(sanitizeSchema(schema)); + assertEqual(result.type, 'STRING', 'Type should be uppercase STRING'); + }); + + // Test 2: Object type conversion + test('Object type conversion', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + }; + const result = cleanSchemaForGemini(sanitizeSchema(schema)); + assertEqual(result.type, 'OBJECT', 'Object type should be uppercase'); + assertEqual(result.properties.name.type, 'STRING', 'Nested string type should be uppercase'); + assertEqual(result.properties.age.type, 'INTEGER', 'Nested integer type should be uppercase'); + }); + + // Test 3: Array type conversion (the main bug fix) + test('Array type conversion with items', () => { + const schema = { + type: 'array', + items: { + type: 'string' + } + }; + const result = cleanSchemaForGemini(sanitizeSchema(schema)); + assertEqual(result.type, 'ARRAY', 'Array type should be uppercase ARRAY'); + assertEqual(result.items.type, 'STRING', 'Items type should be uppercase STRING'); + }); + + // Test 4: Nested array inside object (the actual bug scenario) + test('Nested array inside object (Claude Code TodoWrite-style schema)', () => { + const schema = { + type: 'object', + properties: { + todos: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + title: { type: 'string' }, + status: { type: 'string' } + } + } + } + } + }; + const result = cleanSchemaForGemini(sanitizeSchema(schema)); + + assertEqual(result.type, 'OBJECT', 'Root type should be OBJECT'); + assertEqual(result.properties.todos.type, 'ARRAY', 'Todos type should be ARRAY'); + assertEqual(result.properties.todos.items.type, 'OBJECT', 'Items type should be OBJECT'); + assertEqual(result.properties.todos.items.properties.id.type, 'INTEGER', 'id type should be INTEGER'); + assertEqual(result.properties.todos.items.properties.title.type, 'STRING', 'title type should be STRING'); + }); + + // Test 5: Complex nested structure (simulating Claude Code tools) + test('Complex nested structure with multiple array levels', () => { + const schema = { + type: 'object', + properties: { + tasks: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + subtasks: { + type: 'array', + items: { + type: 'object', + properties: { + step: { type: 'string' }, + completed: { type: 'boolean' } + } + } + } + } + } + }, + count: { type: 'number' } + } + }; + const result = cleanSchemaForGemini(sanitizeSchema(schema)); + + assertEqual(result.type, 'OBJECT'); + assertEqual(result.properties.tasks.type, 'ARRAY'); + assertEqual(result.properties.tasks.items.type, 'OBJECT'); + assertEqual(result.properties.tasks.items.properties.subtasks.type, 'ARRAY'); + assertEqual(result.properties.tasks.items.properties.subtasks.items.type, 'OBJECT'); + assertEqual(result.properties.tasks.items.properties.subtasks.items.properties.completed.type, 'BOOLEAN'); + assertEqual(result.properties.count.type, 'NUMBER'); + }); + + // Test 6: cleanSchemaForGemini handles anyOf (when not stripped by sanitizeSchema) + test('cleanSchemaForGemini handles anyOf and converts types', () => { + // Test cleanSchemaForGemini directly with anyOf (bypassing sanitizeSchema) + const schema = { + type: 'object', + properties: { + value: { + anyOf: [ + { type: 'string' }, + { type: 'object', properties: { name: { type: 'string' } } } + ] + } + } + }; + const result = cleanSchemaForGemini(schema); + + assertEqual(result.type, 'OBJECT'); + // anyOf gets flattened to best option (object type scores highest) + assertEqual(result.properties.value.type, 'OBJECT'); + }); + + // Test 7: Schema with type array (nullable) + test('Schema with type array (nullable) gets flattened and converted', () => { + const schema = { + type: 'object', + properties: { + optional: { + type: ['string', 'null'] + } + } + }; + const result = cleanSchemaForGemini(sanitizeSchema(schema)); + + assertEqual(result.type, 'OBJECT'); + assertEqual(result.properties.optional.type, 'STRING'); + }); + + // Test 8: All primitive types + test('All primitive types converted correctly', () => { + const schema = { + type: 'object', + properties: { + str: { type: 'string' }, + num: { type: 'number' }, + int: { type: 'integer' }, + bool: { type: 'boolean' }, + arr: { type: 'array', items: { type: 'string' } }, + obj: { type: 'object', properties: { x: { type: 'string' } } } + } + }; + const result = cleanSchemaForGemini(sanitizeSchema(schema)); + + assertEqual(result.properties.str.type, 'STRING'); + assertEqual(result.properties.num.type, 'NUMBER'); + assertEqual(result.properties.int.type, 'INTEGER'); + assertEqual(result.properties.bool.type, 'BOOLEAN'); + assertEqual(result.properties.arr.type, 'ARRAY'); + assertEqual(result.properties.obj.type, 'OBJECT'); + }); + + // Test 9: Empty schema gets placeholder with correct types + test('Empty schema gets placeholder with uppercase types', () => { + const result = cleanSchemaForGemini(sanitizeSchema(null)); + + assertEqual(result.type, 'OBJECT'); + assertEqual(result.properties.reason.type, 'STRING'); + }); + + // Test 10: Real-world Claude Code tool schema simulation + test('Real-world Claude Code ManageTodoList-style schema', () => { + // Simulates the type of schema that caused issue #82 + const schema = { + type: 'object', + properties: { + operation: { + type: 'string', + enum: ['write', 'read'] + }, + todoList: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'number' }, + title: { type: 'string' }, + status: { + type: 'string', + enum: ['not-started', 'in-progress', 'completed'] + } + }, + required: ['id', 'title', 'status'] + } + } + }, + required: ['operation'] + }; + + const result = cleanSchemaForGemini(sanitizeSchema(schema)); + + // Verify all types are uppercase + assertEqual(result.type, 'OBJECT'); + assertEqual(result.properties.operation.type, 'STRING'); + assertEqual(result.properties.todoList.type, 'ARRAY'); + assertEqual(result.properties.todoList.items.type, 'OBJECT'); + assertEqual(result.properties.todoList.items.properties.id.type, 'NUMBER'); + assertEqual(result.properties.todoList.items.properties.title.type, 'STRING'); + assertEqual(result.properties.todoList.items.properties.status.type, 'STRING'); + }); + + // Summary + console.log('\n' + '═'.repeat(60)); + console.log(`Tests completed: ${passed} passed, ${failed} failed`); + + if (failed > 0) { + process.exit(1); + } +} + +runTests().catch(err => { + console.error('Test suite failed:', err); + process.exit(1); +});