diff --git a/src/cloudcode/sse-streamer.js b/src/cloudcode/sse-streamer.js index 9d72bc0..7fb640a 100644 --- a/src/cloudcode/sse-streamer.js +++ b/src/cloudcode/sse-streamer.js @@ -123,8 +123,8 @@ export async function* streamSSEResponse(response, originalModel) { }; } else if (part.text !== undefined) { - // Skip empty text parts - if (!part.text || part.text.trim().length === 0) { + // Skip empty text parts (but preserve whitespace-only chunks for proper spacing) + if (part.text === '') { continue; } diff --git a/tests/run-all.cjs b/tests/run-all.cjs index 95015d3..01256cd 100644 --- a/tests/run-all.cjs +++ b/tests/run-all.cjs @@ -18,7 +18,8 @@ const tests = [ { name: 'Cross-Model Thinking', file: 'test-cross-model-thinking.cjs' }, { name: 'OAuth No-Browser Mode', file: 'test-oauth-no-browser.cjs' }, { name: 'Empty Response Retry', file: 'test-empty-response-retry.cjs' }, - { name: 'Schema Sanitizer', file: 'test-schema-sanitizer.cjs' } + { name: 'Schema Sanitizer', file: 'test-schema-sanitizer.cjs' }, + { name: 'Streaming Whitespace', file: 'test-streaming-whitespace.cjs' } ]; async function runTest(test) { diff --git a/tests/test-streaming-whitespace.cjs b/tests/test-streaming-whitespace.cjs new file mode 100644 index 0000000..ad05fb2 --- /dev/null +++ b/tests/test-streaming-whitespace.cjs @@ -0,0 +1,119 @@ +/** + * Test Streaming Whitespace - Verifies that whitespace-only chunks are not dropped + * + * Reproduction for Issue #138: "Claude models swallow spaces between words" + */ + +const { TextEncoder } = require('util'); + +// Mock Response class +class MockResponse { + constructor(chunks) { + this.body = { + getReader: () => { + let index = 0; + return { + read: async () => { + if (index >= chunks.length) { + return { done: true, value: undefined }; + } + const chunk = chunks[index++]; + const encoder = new TextEncoder(); + return { done: false, value: encoder.encode(chunk) }; + } + }; + } + }; + } +} + +async function runTests() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ STREAMING WHITESPACE TEST SUITE ║'); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Dynamic import for ESM module + const { streamSSEResponse } = await import('../src/cloudcode/sse-streamer.js'); + + let passed = 0; + let failed = 0; + + async function test(name, fn) { + try { + await fn(); + console.log(`✓ ${name}`); + passed++; + } catch (e) { + console.log(`✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } + } + + function assertEqual(actual, expected, message = '') { + if (actual !== expected) { + throw new Error(`${message}\nExpected: "${expected}"\nActual: "${actual}"`); + } + } + + // Test Case: Whitespace preservation + await test('Preserves whitespace-only chunks', async () => { + // Construct chunks that simulate the Google SSE format + // We split "Hello World" into "Hello", " ", "World" + const chunks = [ + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Hello" }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: " " }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "World" }] } }] }) + '\n\n' + ]; + + const response = new MockResponse(chunks); + const originalModel = 'claude-sonnet-4-5'; + + let fullText = ''; + const generator = streamSSEResponse(response, originalModel); + + for await (const event of generator) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + fullText += event.delta.text; + } + } + + assertEqual(fullText, 'Hello World', 'Should preserve space between words'); + }); + + // Test Case: Empty string (should be skipped) + await test('Skips truly empty strings but keeps newlines', async () => { + const chunks = [ + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Line1" }] } }] }) + '\n\n', + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "" }] } }] }) + '\n\n', // Empty + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "\n" }] } }] }) + '\n\n', // Newline + 'data: ' + JSON.stringify({ candidates: [{ content: { parts: [{ text: "Line2" }] } }] }) + '\n\n' + ]; + + const response = new MockResponse(chunks); + const originalModel = 'claude-sonnet-4-5'; + + let fullText = ''; + const generator = streamSSEResponse(response, originalModel); + + for await (const event of generator) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + fullText += event.delta.text; + } + } + + assertEqual(fullText, 'Line1\nLine2', 'Should preserve newline but ignore empty string'); + }); + + 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); +});