diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dab5878..f3ed974a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ Format: [Keep a Changelog](https://keepachangelog.com/) +## [3.4.5] - 2025-11-11 + +### Fixed +- Thinking block signature timing race (blocks appeared blank in Claude CLI UI) +- Content verification guard in `_createSignatureDeltaEvent()` returns null if empty + +### Changed +- Consolidated debug flags: `CCS_DEBUG_LOG`, `CCS_GLMT_DEBUG` → `CCS_DEBUG` only + +### Added +- 6 regression tests for thinking signature race (`test-thinking-signature-race.js`) + +--- + ## [3.4.4] - 2025-11-11 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 952b9393..4ae95936 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ ccs glmt "ultrathink this complex algorithm optimization" - `` - Control reasoning depth **Environment variables**: -- `CCS_DEBUG=1` - Enable debug file logging to ~/.ccs/logs/ +- `CCS_DEBUG=1` - Enable debug logging (file logging to ~/.ccs/logs/ + enhanced console diagnostics) **Security limits** (DoS protection): - SSE buffer: 1MB max @@ -372,6 +372,12 @@ All values = strings (not booleans/objects) to prevent PowerShell crashes. - Try using "think" keywords in prompt: `ccs glmt "think about the solution"` - Test with `ccs glm` (no thinking) to isolate proxy issues +**Blank/Empty Thinking Blocks** (v3.5.1+ fix): +- Fixed: Signature timing race where signature sent before content accumulated +- Solution: Content verification guard returns null if empty, 3 callers handle gracefully +- Diagnostics: `export CCS_DEBUG=1` shows reasoning deltas, block creation, signature timing +- Regression tests: `tests/unit/glmt/test-thinking-signature-race.js` (6 tests) + **Chinese Output / Unexpected Language**: - Locale enforcer always injects "MUST respond in English" into system prompts - If issues persist, check Z.AI API configuration @@ -401,6 +407,12 @@ ccs glmt --verbose "test" export CCS_DEBUG=1 ccs glmt --verbose "test" # Logs: ~/.ccs/logs/ + +# Enhanced diagnostics (file + console) +export CCS_DEBUG=1 +ccs glmt "think about complex task" +# File logs: ~/.ccs/logs/ +# Console: reasoning deltas, block creation, signature timing ``` ## Error Handling diff --git a/README.md b/README.md index 13b29c69..8741a111 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,24 @@ Commands and skills symlinked from `~/.ccs/shared/` - no duplication across prof > **[!] Important**: GLMT requires npm installation (`npm install -g @kaitranntt/ccs`). Not available in native shell versions (requires Node.js HTTP server). +### Acknowledgments: The Foundation That Made GLMT Possible + +> **[i] Pioneering Work by [@Bedolla](https://github.com/Bedolla)** +> +> **CCS's GLMT implementation owes its existence to the groundbreaking work of [@Bedolla](https://github.com/Bedolla)**, who created [ZaiTransformer](https://github.com/Bedolla/ZaiTransformer/) - the **first integration** to bridge [Claude Code Router (CCR)](https://github.com/musistudio/claude-code-router) with Z.AI's reasoning capabilities. +> +> **Why this matters**: Before ZaiTransformer, no one had successfully integrated Z.AI's thinking mode with Claude Code's workflow. Bedolla's work wasn't just helpful - it was **foundational**. His implementation of: +> +> - **Request/response transformation architecture** - The conceptual blueprint for how to bridge Anthropic and OpenAI formats +> - **Thinking mode control mechanisms** - The patterns for managing reasoning_content delivery +> - **Embedded proxy design** - The architecture that CCS's GLMT proxy is built upon +> +> These contributions directly inspired and enabled GLMT's design. **Without ZaiTransformer's pioneering work, GLMT wouldn't exist in its current form**. The technical patterns, transformation logic, and proxy architecture implemented in CCS are a direct evolution of the concepts Bedolla first proved viable. +> +> **Recognition**: If you benefit from GLMT's thinking capabilities, you're benefiting from Bedolla's vision and engineering. Please consider starring [ZaiTransformer](https://github.com/Bedolla/ZaiTransformer/) to support pioneering work in the Claude Code ecosystem. + +--- + ### GLM vs GLMT | Feature | GLM (`ccs glm`) | GLMT (`ccs glmt`) | diff --git a/VERSION b/VERSION index f9892605..4f5e6973 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.4.4 +3.4.5 diff --git a/bin/glmt/delta-accumulator.js b/bin/glmt/delta-accumulator.js index b6200db2..c39e71de 100644 --- a/bin/glmt/delta-accumulator.js +++ b/bin/glmt/delta-accumulator.js @@ -100,22 +100,32 @@ class DeltaAccumulator { */ addDelta(delta) { const block = this.getCurrentBlock(); - if (block) { - if (block.type === 'thinking') { - // C-02 Fix: Enforce buffer size limit - if (this.thinkingBuffer.length + delta.length > this.maxBufferSize) { - throw new Error(`Thinking buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); - } - this.thinkingBuffer += delta; - block.content = this.thinkingBuffer; - } else if (block.type === 'text') { - // C-02 Fix: Enforce buffer size limit - if (this.textBuffer.length + delta.length > this.maxBufferSize) { - throw new Error(`Text buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); - } - this.textBuffer += delta; - block.content = this.textBuffer; + if (!block) { + // FIX: Guard against null block (should never happen, but defensive) + console.error('[DeltaAccumulator] ERROR: addDelta called with no current block'); + return; + } + + if (block.type === 'thinking') { + // C-02 Fix: Enforce buffer size limit + if (this.thinkingBuffer.length + delta.length > this.maxBufferSize) { + throw new Error(`Thinking buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); } + this.thinkingBuffer += delta; + block.content = this.thinkingBuffer; + + // FIX: Verify assignment succeeded (paranoid check for race conditions) + if (block.content.length !== this.thinkingBuffer.length) { + console.error('[DeltaAccumulator] ERROR: Block content assignment failed'); + console.error(`Expected: ${this.thinkingBuffer.length}, Got: ${block.content.length}`); + } + } else if (block.type === 'text') { + // C-02 Fix: Enforce buffer size limit + if (this.textBuffer.length + delta.length > this.maxBufferSize) { + throw new Error(`Text buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); + } + this.textBuffer += delta; + block.content = this.textBuffer; } } @@ -126,6 +136,11 @@ class DeltaAccumulator { const block = this.getCurrentBlock(); if (block) { block.stopped = true; + + // FIX: Log block closure for debugging (helps diagnose timing issues) + if (block.type === 'thinking' && process.env.CCS_DEBUG === '1') { + console.error(`[DeltaAccumulator] Stopped thinking block ${block.index}: ${block.content?.length || 0} chars`); + } } } diff --git a/bin/glmt/glmt-transformer.js b/bin/glmt/glmt-transformer.js index d2c5c4de..5df2ca95 100644 --- a/bin/glmt/glmt-transformer.js +++ b/bin/glmt/glmt-transformer.js @@ -17,7 +17,7 @@ const LocaleEnforcer = require('./locale-enforcer'); * - Response: OpenAI reasoning_content → Anthropic thinking blocks * - Tool Support: Anthropic tools ↔ OpenAI function calling (bidirectional) * - Streaming: Real-time tool calls with input_json deltas - * - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG_LOG=1) + * - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG=1) * - Verbose mode: Console logging with timestamps * - Validation: Self-test transformation results * @@ -37,16 +37,10 @@ class GlmtTransformer { this.defaultThinking = config.defaultThinking ?? true; this.verbose = config.verbose || false; - // Support both CCS_DEBUG and CCS_DEBUG_LOG (with deprecation warning) - const oldVar = process.env.CCS_DEBUG_LOG === '1'; - const newVar = process.env.CCS_DEBUG === '1'; - this.debugLog = config.debugLog ?? (newVar || oldVar); - - // Show deprecation warning once - if (oldVar && !newVar && !GlmtTransformer._warnedDeprecation) { - console.warn('[glmt] Warning: CCS_DEBUG_LOG is deprecated, use CCS_DEBUG instead'); - GlmtTransformer._warnedDeprecation = true; - } + // CCS_DEBUG controls all debug logging (file + console) + const debugEnabled = process.env.CCS_DEBUG === '1'; + this.debugLog = config.debugLog ?? debugEnabled; + this.debugMode = config.debugMode ?? debugEnabled; this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs'); this.modelMaxTokens = { @@ -645,10 +639,20 @@ class GlmtTransformer { if (delta.reasoning_content) { const currentBlock = accumulator.getCurrentBlock(); + // FIX: Enhanced debug logging for thinking block diagnostics + if (this.debugMode) { + console.error(`[GLMT-DEBUG] Reasoning delta: ${delta.reasoning_content.length} chars`); + console.error(`[GLMT-DEBUG] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}`); + } + if (!currentBlock || currentBlock.type !== 'thinking') { // Start thinking block const block = accumulator.startBlock('thinking'); events.push(this._createContentBlockStartEvent(block)); + + if (this.debugMode) { + console.error(`[GLMT-DEBUG] Started new thinking block ${block.index}`); + } } accumulator.addDelta(delta.reasoning_content); @@ -664,7 +668,10 @@ class GlmtTransformer { // Close thinking block if transitioning from thinking to text if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) { - events.push(this._createSignatureDeltaEvent(currentBlock)); + const signatureEvent = this._createSignatureDeltaEvent(currentBlock); + if (signatureEvent) { // FIX: Handle null return from signature race guard + events.push(signatureEvent); + } events.push(this._createContentBlockStopEvent(currentBlock)); accumulator.stopCurrentBlock(); } @@ -691,7 +698,10 @@ class GlmtTransformer { const currentBlock = accumulator.getCurrentBlock(); if (currentBlock && !currentBlock.stopped) { if (currentBlock.type === 'thinking') { - events.push(this._createSignatureDeltaEvent(currentBlock)); + const signatureEvent = this._createSignatureDeltaEvent(currentBlock); + if (signatureEvent) { // FIX: Handle null return from signature race guard + events.push(signatureEvent); + } } events.push(this._createContentBlockStopEvent(currentBlock)); accumulator.stopCurrentBlock(); @@ -794,7 +804,10 @@ class GlmtTransformer { const currentBlock = accumulator.getCurrentBlock(); if (currentBlock && !currentBlock.stopped) { if (currentBlock.type === 'thinking') { - events.push(this._createSignatureDeltaEvent(currentBlock)); + const signatureEvent = this._createSignatureDeltaEvent(currentBlock); + if (signatureEvent) { // FIX: Handle null return from signature race guard + events.push(signatureEvent); + } } events.push(this._createContentBlockStopEvent(currentBlock)); accumulator.stopCurrentBlock(); @@ -914,7 +927,23 @@ class GlmtTransformer { * @private */ _createSignatureDeltaEvent(block) { + // FIX: Guard against empty content (signature timing race) + // In streaming mode, signature may be requested before content fully accumulated + if (!block.content || block.content.length === 0) { + if (this.verbose) { + this.log(`WARNING: Skipping signature for empty thinking block ${block.index}`); + this.log(`This indicates a race condition - signature requested before content accumulated`); + } + return null; // Return null instead of event + } + const signature = this._generateThinkingSignature(block.content); + + // Enhanced logging for debugging + if (this.verbose) { + this.log(`Generating signature for block ${block.index}: ${block.content.length} chars`); + } + return { event: 'content_block_delta', data: { diff --git a/installers/install.ps1 b/installers/install.ps1 index 6034a851..74155b50 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -31,7 +31,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or ( # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (irm | iex) # For git installations, VERSION file is read if available -$CcsVersion = "3.4.3" +$CcsVersion = "3.4.5" # Try to read VERSION file for git installations if ($ScriptDir) { diff --git a/installers/install.sh b/installers/install.sh index 19896065..7629e10d 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -32,7 +32,7 @@ fi # IMPORTANT: Update this version when releasing new versions! # This hardcoded version is used for standalone installations (curl | bash) # For git installations, VERSION file is read if available -CCS_VERSION="3.4.3" +CCS_VERSION="3.4.5" # Try to read VERSION file for git installations if [[ -f "$SCRIPT_DIR/VERSION" ]]; then diff --git a/lib/ccs b/lib/ccs index 5a35d470..e5536bbe 100755 --- a/lib/ccs +++ b/lib/ccs @@ -2,7 +2,7 @@ set -euo pipefail # Version (updated by scripts/bump-version.sh) -CCS_VERSION="3.4.3" +CCS_VERSION="3.4.5" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}" readonly PROFILES_JSON="$HOME/.ccs/profiles.json" diff --git a/lib/ccs.ps1 b/lib/ccs.ps1 index 31cdc47b..38cfac90 100644 --- a/lib/ccs.ps1 +++ b/lib/ccs.ps1 @@ -12,7 +12,7 @@ param( $ErrorActionPreference = "Stop" # Version (updated by scripts/bump-version.sh) -$CcsVersion = "3.4.3" +$CcsVersion = "3.4.5" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" } $ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json" diff --git a/package.json b/package.json index 81be578b..4c91de06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "3.4.4", + "version": "3.4.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/tests/unit/glmt/debug-mode-test.js b/tests/unit/glmt/debug-mode-test.js index a39632a9..474a7672 100755 --- a/tests/unit/glmt/debug-mode-test.js +++ b/tests/unit/glmt/debug-mode-test.js @@ -152,12 +152,12 @@ console.log('\n=== Test 2: Debug Mode ON (via config) ==='); console.log('\n✓ All checks passed for debug mode ON'); } -console.log('\n=== Test 3: Debug Mode via CCS_DEBUG_LOG=1 ==='); +console.log('\n=== Test 3: Debug Mode via CCS_DEBUG=1 ==='); { // Clean up fs.rmSync(logDir, { recursive: true, force: true }); - process.env.CCS_DEBUG_LOG = '1'; + process.env.CCS_DEBUG = '1'; const transformer = new GlmtTransformer({ verbose: false }); console.log(`Debug logging: ${transformer.debugLog}`); @@ -188,13 +188,13 @@ console.log('\n=== Test 3: Debug Mode via CCS_DEBUG_LOG=1 ==='); console.log(`Files created: ${files.length}`); if (files.length === 4) { - console.log('✓ Debug mode enabled via CCS_DEBUG_LOG=1'); + console.log('✓ Debug mode enabled via CCS_DEBUG=1'); } else { console.log(`ERROR: Expected 4 files, got ${files.length}`); process.exit(1); } - delete process.env.CCS_DEBUG_LOG; + delete process.env.CCS_DEBUG; } console.log('\n=== Test 4: Error Handling (No Write Permission) ==='); diff --git a/tests/unit/glmt/glmt-transformer.test.js b/tests/unit/glmt/glmt-transformer.test.js index e4744c90..39607960 100644 --- a/tests/unit/glmt/glmt-transformer.test.js +++ b/tests/unit/glmt/glmt-transformer.test.js @@ -288,11 +288,11 @@ runner.test('debug mode enabled via config', () => { }); // Test 15: Debug mode enabled via env var -runner.test('debug mode enabled via CCS_DEBUG_LOG=1', () => { - process.env.CCS_DEBUG_LOG = '1'; +runner.test('debug mode enabled via CCS_DEBUG=1', () => { + process.env.CCS_DEBUG = '1'; const transformer = new GlmtTransformer(); - assertEqual(transformer.debugLog, true, 'debugLog should be true when CCS_DEBUG_LOG=1'); - delete process.env.CCS_DEBUG_LOG; + assertEqual(transformer.debugLog, true, 'debugLog should be true when CCS_DEBUG=1'); + delete process.env.CCS_DEBUG; }); // Test 16: Debug log directory path diff --git a/tests/unit/glmt/test-thinking-multi-message.js b/tests/unit/glmt/test-thinking-multi-message.js index 0a9304d7..d909fad7 100644 --- a/tests/unit/glmt/test-thinking-multi-message.js +++ b/tests/unit/glmt/test-thinking-multi-message.js @@ -7,7 +7,7 @@ * Simulates 3 consecutive messages to test if thinking blocks * appear in all messages or only the first one. * - * Usage: CCS_DEBUG_LOG=1 node test-thinking-multi-message.js + * Usage: CCS_DEBUG=1 node test-thinking-multi-message.js */ const { spawn } = require('child_process'); @@ -67,7 +67,7 @@ async function runMessage(messageIndex) { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, - CCS_DEBUG_LOG: '1' + CCS_DEBUG: '1' } }); diff --git a/tests/unit/glmt/test-thinking-signature-race.js b/tests/unit/glmt/test-thinking-signature-race.js new file mode 100755 index 00000000..b5c7442b --- /dev/null +++ b/tests/unit/glmt/test-thinking-signature-race.js @@ -0,0 +1,250 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Regression test for thinking signature race condition + * Ensures signature not generated before content accumulated + * + * Issue: In streaming mode, thinking blocks appeared in UI but were blank + * Root cause: _createSignatureDeltaEvent called before block.content accumulated + * Fix: Guard against empty content, return null if block.content is empty + */ + +const GlmtTransformer = require('../../../bin/glmt/glmt-transformer'); +const DeltaAccumulator = require('../../../bin/glmt/delta-accumulator'); + +// Test runner +class TestRunner { + constructor() { + this.tests = []; + this.passed = 0; + this.failed = 0; + } + + test(name, fn) { + this.tests.push({ name, fn }); + } + + async run() { + console.log('\n=== Thinking Signature Race Condition Tests ===\n'); + + for (const { name, fn } of this.tests) { + try { + await fn(); + console.log(`✓ ${name}`); + this.passed++; + } catch (error) { + console.log(`✗ ${name}`); + console.log(` Error: ${error.message}`); + this.failed++; + } + } + + console.log('\n=== Results ==='); + console.log(`Passed: ${this.passed}/${this.tests.length}`); + console.log(`Failed: ${this.failed}/${this.tests.length}`); + + process.exit(this.failed > 0 ? 1 : 0); + } +} + +const runner = new TestRunner(); + +// Helper function for assertions +function assert(condition, message) { + if (!condition) { + throw new Error(message || 'Assertion failed'); + } +} + +// Test 1: Signature not generated for empty thinking block +runner.test('Signature not generated for empty thinking block', () => { + const transformer = new GlmtTransformer({ verbose: false }); + const accumulator = new DeltaAccumulator({ thinking: true }); + + // Start thinking block but add no content + accumulator.messageStarted = true; + const block = accumulator.startBlock('thinking'); + + // Try to generate signature for empty block + const signatureEvent = transformer._createSignatureDeltaEvent(block); + + // Should return null for empty block (fix for race condition) + assert(signatureEvent === null, 'Expected null for empty thinking block'); +}); + +// Test 2: Signature generated correctly after content accumulated +runner.test('Signature generated correctly after content accumulated', () => { + const transformer = new GlmtTransformer({ verbose: false }); + const accumulator = new DeltaAccumulator({ thinking: true }); + + // Start thinking block and add content + accumulator.messageStarted = true; + const block = accumulator.startBlock('thinking'); + accumulator.addDelta('First thinking delta. '); + accumulator.addDelta('Second thinking delta.'); + + // Generate signature + const signatureEvent = transformer._createSignatureDeltaEvent(block); + + // Should return valid signature event + assert(signatureEvent !== null, 'Expected signature event for non-empty block'); + assert(signatureEvent.event === 'content_block_delta', 'Expected content_block_delta event'); + assert(signatureEvent.data.delta.type === 'thinking_signature_delta', 'Expected thinking_signature_delta type'); + assert(signatureEvent.data.delta.signature.length > 0, 'Expected signature length > 0'); + assert(signatureEvent.data.delta.signature.hash, 'Expected signature hash'); + assert(signatureEvent.data.delta.signature.hash.length === 16, 'Expected 16-char hash'); +}); + +// Test 3: transformDelta skips signature for empty thinking blocks +runner.test('transformDelta skips signature for empty thinking blocks (thinking→text transition)', () => { + const transformer = new GlmtTransformer({ verbose: false }); + const accumulator = new DeltaAccumulator({ thinking: true }); + + // Simulate thinking block with no content + accumulator.messageStarted = true; + const block = accumulator.startBlock('thinking'); + + // Transition to text (would normally generate signature) + const openaiEvent = { + event: 'message', + data: { + choices: [{ + delta: { content: 'Text response after empty thinking' } + }] + } + }; + + const events = transformer.transformDelta(openaiEvent, accumulator); + + // Signature event should not be present + const signatureEvents = events.filter(e => + e.data?.delta?.type === 'thinking_signature_delta' + ); + assert(signatureEvents.length === 0, 'Expected 0 signature events for empty thinking block'); + + // Should still have content_block_stop event + const stopEvents = events.filter(e => e.event === 'content_block_stop'); + assert(stopEvents.length > 0, 'Expected content_block_stop event'); +}); + +// Test 4: transformDelta generates signature for non-empty thinking blocks +runner.test('transformDelta generates signature for non-empty thinking blocks', () => { + const transformer = new GlmtTransformer({ verbose: false }); + const accumulator = new DeltaAccumulator({ thinking: true }); + + // Start thinking block with content + accumulator.messageStarted = true; + const block = accumulator.startBlock('thinking'); + + // Add reasoning content + const reasoningEvent = { + event: 'message', + data: { + choices: [{ + delta: { reasoning_content: 'This is actual thinking content. ' } + }] + } + }; + transformer.transformDelta(reasoningEvent, accumulator); + + // Add more content + const moreReasoningEvent = { + event: 'message', + data: { + choices: [{ + delta: { reasoning_content: 'More thinking here.' } + }] + } + }; + transformer.transformDelta(moreReasoningEvent, accumulator); + + // Now transition to text (should generate signature) + const textEvent = { + event: 'message', + data: { + choices: [{ + delta: { content: 'Final text response' } + }] + } + }; + + const events = transformer.transformDelta(textEvent, accumulator); + + // Signature event should be present + const signatureEvents = events.filter(e => + e.data?.delta?.type === 'thinking_signature_delta' + ); + assert(signatureEvents.length === 1, 'Expected 1 signature event for non-empty thinking block'); + + // Verify signature structure + const sig = signatureEvents[0].data.delta.signature; + assert(sig.hash && sig.hash.length === 16, 'Expected valid 16-char hash'); + assert(sig.length > 0, 'Expected content length > 0'); +}); + +// Test 5: Loop detection handles empty thinking blocks +runner.test('Loop detection handles empty thinking blocks without signature', () => { + const transformer = new GlmtTransformer({ verbose: false }); + const accumulator = new DeltaAccumulator({ thinking: true }); + + // Create scenario where loop is detected with empty thinking block + accumulator.messageStarted = true; + + // First thinking block (empty) + accumulator.startBlock('thinking'); + accumulator.stopCurrentBlock(); + + // Second thinking block (empty) + accumulator.startBlock('thinking'); + accumulator.stopCurrentBlock(); + + // Third thinking block (empty) - triggers loop + accumulator.startBlock('thinking'); + + // Simulate loop detection in transformDelta + const openaiEvent = { + event: 'message', + data: { + choices: [{ + delta: { reasoning_content: '' } + }] + } + }; + + // This would trigger loop detection logic + accumulator.checkForLoop(); // Returns true after 3 blocks + + const currentBlock = accumulator.getCurrentBlock(); + if (currentBlock && currentBlock.type === 'thinking') { + const signatureEvent = transformer._createSignatureDeltaEvent(currentBlock); + // Should return null for empty block + assert(signatureEvent === null, 'Expected null signature for empty thinking block during loop detection'); + } +}); + +// Test 6: finalizeDelta handles empty thinking blocks +runner.test('finalizeDelta handles empty thinking blocks without signature', () => { + const transformer = new GlmtTransformer({ verbose: false }); + const accumulator = new DeltaAccumulator({ thinking: true }); + + // Start thinking block with no content + accumulator.messageStarted = true; + accumulator.startBlock('thinking'); + + // Finalize (end of message) + const events = transformer.finalizeDelta(accumulator); + + // Should not include signature event for empty block + const signatureEvents = events.filter(e => + e.data?.delta?.type === 'thinking_signature_delta' + ); + assert(signatureEvents.length === 0, 'Expected 0 signature events for empty thinking block in finalizeDelta'); + + // Should still include content_block_stop + const stopEvents = events.filter(e => e.event === 'content_block_stop'); + assert(stopEvents.length === 1, 'Expected content_block_stop event in finalizeDelta'); +}); + +// Run all tests +runner.run();