fix: convert schema types to Google uppercase format (fixes #82)

The /compact command was failing with 'Proto field is not repeating,
cannot start list' error for Claude models because tool schemas were
sent with lowercase JSON Schema types (array, object, string) but
Google's Cloud Code API expects uppercase protobuf types (ARRAY,
OBJECT, STRING).

Changes:
- Add toGoogleType() function to convert JSON Schema types to Google format
- Add Phase 5 to cleanSchemaForGemini() for type conversion
- Apply cleanSchemaForGemini() for ALL models (not just Gemini) since
  all requests go through Cloud Code API which validates schema format
- Add comprehensive test suite with 10 tests covering nested arrays,
  complex schemas, and real-world Claude Code tool scenarios

Fixes #82
This commit is contained in:
Tiago Rodrigues
2026-01-09 18:36:19 +00:00
parent 348fdc3f94
commit 90214c43b0
3 changed files with 301 additions and 4 deletions

View File

@@ -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);
});