feat: implement stability fixes and enhance error handling
This commit is contained in:
1 parent
b0ec96d4a6
commit
5772e2e90e
17 files changed
+1161
-108
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
Usage,
|
||||
GroundingMetadata,
|
||||
} from './types';
|
||||
import { decodeSignature } from './signature-utils';
|
||||
|
||||
/**
|
||||
* Non-streaming response processor (Gemini -> Claude)
|
||||
@@ -55,7 +56,7 @@ class NonStreamingProcessor {
|
||||
}
|
||||
|
||||
private processPart(part: GeminiPart) {
|
||||
const signature = part.thoughtSignature || null;
|
||||
const signature = decodeSignature(part.thoughtSignature) || null;
|
||||
|
||||
// 1. Handle FunctionCall
|
||||
if (part.functionCall) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GeminiPart, Usage, UsageMetadata, GroundingMetadata } from './types';
|
||||
import { GeminiPart, Usage, UsageMetadata } from './types';
|
||||
import { SignatureStore } from './SignatureStore';
|
||||
import { decodeSignature } from './signature-utils';
|
||||
|
||||
type BlockType = 'None' | 'Text' | 'Thinking' | 'Function';
|
||||
|
||||
@@ -43,6 +44,8 @@ export class StreamingState {
|
||||
public webSearchQuery: string | null = null;
|
||||
public groundingChunks: any[] | null = null;
|
||||
|
||||
private parseErrorCount: number = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public emit(eventType: string, data: any): string {
|
||||
@@ -242,6 +245,56 @@ export class StreamingState {
|
||||
public storeSignature(signature?: string) {
|
||||
this.signatures.store(signature);
|
||||
}
|
||||
public handleParseError(rawData: string): string[] {
|
||||
const chunks: string[] = [];
|
||||
this.parseErrorCount++;
|
||||
|
||||
console.warn(
|
||||
`[SSE-Parser] Parse error #${this.parseErrorCount}. Raw data length: ${rawData.length}`,
|
||||
);
|
||||
|
||||
// Safely close current block
|
||||
if (this.blockType !== 'None') {
|
||||
chunks.push(...this.endBlock());
|
||||
}
|
||||
|
||||
// Emit error event if too many errors
|
||||
if (this.parseErrorCount > 3) {
|
||||
console.error(
|
||||
`[SSE-Parser] High error rate (${this.parseErrorCount} errors). Stream may be corrupted.`,
|
||||
);
|
||||
chunks.push(
|
||||
this.emit('error', {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'network_error',
|
||||
message: 'Unstable network connection. Please check your network or proxy settings.',
|
||||
code: 'stream_decode_error',
|
||||
details: {
|
||||
error_count: this.parseErrorCount,
|
||||
suggestion: 'Check network connection',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset error state (call after recovery)
|
||||
*/
|
||||
public resetErrorState(): void {
|
||||
this.parseErrorCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current error count (for monitoring)
|
||||
*/
|
||||
public getErrorCount(): number {
|
||||
return this.parseErrorCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +305,7 @@ export class PartProcessor {
|
||||
|
||||
public process(part: GeminiPart): string[] {
|
||||
const chunks: string[] = [];
|
||||
const signature = part.thoughtSignature || undefined;
|
||||
const signature = decodeSignature(part.thoughtSignature);
|
||||
|
||||
// 1. Handle FunctionCall
|
||||
if (part.functionCall) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/** Jitter factor: ±20% */
|
||||
const JITTER_FACTOR = 0.2;
|
||||
|
||||
/** Maximum retry delay in milliseconds (30 seconds) */
|
||||
const MAX_DELAY_MS = 30000;
|
||||
|
||||
/**
|
||||
* Apply jitter to a delay value to prevent thundering herd
|
||||
* Returns delay ± JITTER_FACTOR (e.g., 1000ms ± 20% = 800-1200ms)
|
||||
*
|
||||
* @param delayMs - Base delay in milliseconds
|
||||
* @returns Jittered delay (minimum 1ms)
|
||||
*/
|
||||
export function applyJitter(delayMs: number): number {
|
||||
if (delayMs <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const jitterRange = delayMs * JITTER_FACTOR;
|
||||
const jitter = (Math.random() * 2 - 1) * jitterRange;
|
||||
return Math.max(1, Math.round(delayMs + jitter));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay with exponential backoff and jitter
|
||||
*
|
||||
* @param attempt - Current attempt number (0-indexed)
|
||||
* @param baseMs - Base delay for first retry (default 1000ms)
|
||||
* @returns Delay in milliseconds with jitter applied
|
||||
*/
|
||||
export function calculateRetryDelay(attempt: number, baseMs: number = 1000): number {
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, ...
|
||||
const exponentialDelay = baseMs * Math.pow(2, attempt);
|
||||
|
||||
// Cap at maximum delay
|
||||
const cappedDelay = Math.min(exponentialDelay, MAX_DELAY_MS);
|
||||
|
||||
// Apply jitter
|
||||
return applyJitter(cappedDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*
|
||||
* @param ms - Milliseconds to sleep
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { isEmpty } from 'lodash-es';
|
||||
/**
|
||||
* Decode Base64-encoded signature if present
|
||||
* Gemini sends Base64, Claude expects raw string
|
||||
*
|
||||
* @param signature - The signature string (may be Base64 or raw)
|
||||
* @returns Decoded signature or undefined if input is empty
|
||||
*/
|
||||
export function decodeSignature(signature: string | undefined | null): string | undefined {
|
||||
if (isEmpty(signature)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(signature!, 'base64').toString('utf-8');
|
||||
|
||||
// Validate: Check if it looks like valid UTF-8 text
|
||||
// If decoding produces replacement characters, it wasn't valid Base64
|
||||
if (decoded && !decoded.includes('\ufffd') && decoded.length > 0) {
|
||||
// Additional check: re-encode and compare to detect false positives
|
||||
const reEncoded = Buffer.from(decoded).toString('base64');
|
||||
if (reEncoded === signature) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not valid Base64, return as-is
|
||||
}
|
||||
|
||||
// Return original if not Base64
|
||||
return signature!;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Stream Error Utilities
|
||||
* Classifies network errors and formats user-friendly messages
|
||||
*
|
||||
* Problem: Technical error messages confuse users
|
||||
* Solution: Classify errors and provide localized, actionable messages
|
||||
*/
|
||||
|
||||
export interface ClassifiedError {
|
||||
type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a stream error and return user-friendly message
|
||||
*
|
||||
* @param error - The error object
|
||||
* @returns Classified error with type and localized message
|
||||
*/
|
||||
export function classifyStreamError(error: Error): ClassifiedError {
|
||||
const msg = error.message.toLowerCase();
|
||||
|
||||
if (msg.includes('timeout') || msg.includes('timedout')) {
|
||||
return {
|
||||
type: 'timeout_error',
|
||||
message: 'Request timed out. Please check your network connection or try again later.',
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('econnrefused') || msg.includes('connect')) {
|
||||
return {
|
||||
type: 'connection_error',
|
||||
message: 'Unable to connect to server. Please check your network.',
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('enotfound') || msg.includes('network') || msg.includes('dns')) {
|
||||
return {
|
||||
type: 'network_error',
|
||||
message: 'Network connection failed. Please check your network settings.',
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.includes('socket') || msg.includes('aborted') || msg.includes('reset')) {
|
||||
return {
|
||||
type: 'stream_error',
|
||||
message: 'Data transfer interrupted. Please try again later.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown_error',
|
||||
message: `Unknown error occurred: ${error.message.substring(0, 100)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error as SSE event for Claude protocol
|
||||
*
|
||||
* @param errorType - Error type string
|
||||
* @param message - User-friendly message
|
||||
* @returns SSE-formatted error event string
|
||||
*/
|
||||
export function formatErrorForSSE(errorType: string, message: string): string {
|
||||
const errorEvent = {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
message: message,
|
||||
code: 'stream_error',
|
||||
},
|
||||
};
|
||||
|
||||
return `event: error\ndata: ${JSON.stringify(errorEvent)}\n\n`;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Thinking Block Utilities
|
||||
* Handles validation and graceful degradation of thinking blocks
|
||||
*
|
||||
* Problem: Invalid signatures cause thinking blocks to be dropped, losing content
|
||||
* Solution: Validate and convert invalid blocks to text instead of dropping
|
||||
*/
|
||||
|
||||
/** Minimum length for a valid thought signature */
|
||||
const MIN_SIGNATURE_LENGTH = 10;
|
||||
|
||||
export interface ThinkingBlock {
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
signature?: string;
|
||||
}
|
||||
|
||||
export interface TextBlock {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type ContentBlock = ThinkingBlock | TextBlock | { type: string; [key: string]: any };
|
||||
|
||||
/**
|
||||
* Check if a thinking block has a valid signature
|
||||
*
|
||||
* Rules:
|
||||
* - Empty thinking + any signature = valid (trailing signature case)
|
||||
* - Non-empty thinking + signature >= MIN_SIGNATURE_LENGTH = valid
|
||||
* - Non-empty thinking + no/short signature = invalid
|
||||
* - Non-thinking blocks = always valid
|
||||
*
|
||||
* @param block - Content block to validate
|
||||
* @returns true if block is valid or not a thinking block
|
||||
*/
|
||||
export function hasValidSignature(block: ContentBlock): boolean {
|
||||
if (block.type !== 'thinking') {
|
||||
return true; // Non-thinking blocks are always valid
|
||||
}
|
||||
|
||||
const thinkingBlock = block as ThinkingBlock;
|
||||
|
||||
// Empty thinking + any signature = valid (trailing signature)
|
||||
if (!thinkingBlock.thinking || thinkingBlock.thinking.length === 0) {
|
||||
return (
|
||||
thinkingBlock.signature !== undefined &&
|
||||
thinkingBlock.signature !== null &&
|
||||
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
// Non-empty thinking needs a long enough signature
|
||||
return (
|
||||
thinkingBlock.signature !== undefined &&
|
||||
thinkingBlock.signature !== null &&
|
||||
thinkingBlock.signature.length >= MIN_SIGNATURE_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully degrade an invalid thinking block
|
||||
*
|
||||
* - Valid blocks: return as-is
|
||||
* - Invalid with content: convert to text block
|
||||
* - Invalid without content: return null (should be dropped)
|
||||
*
|
||||
* @param block - Content block to process
|
||||
* @returns Processed block or null if should be dropped
|
||||
*/
|
||||
export function gracefulDegradeThinking(block: ContentBlock): ContentBlock | null {
|
||||
if (block.type !== 'thinking') {
|
||||
return block;
|
||||
}
|
||||
|
||||
const thinkingBlock = block as ThinkingBlock;
|
||||
|
||||
if (hasValidSignature(block)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
// Invalid signature - check if we have content to preserve
|
||||
if (thinkingBlock.thinking && thinkingBlock.thinking.length > 0) {
|
||||
// Convert to text block to preserve content
|
||||
console.info(
|
||||
`[Thinking-Utils] Converting invalid thinking block to text. Content length: ${thinkingBlock.thinking.length}`,
|
||||
);
|
||||
return {
|
||||
type: 'text',
|
||||
text: thinkingBlock.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
// Empty content + invalid signature = drop
|
||||
console.debug('[Thinking-Utils] Dropping empty thinking block with invalid signature');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an array of content blocks, applying graceful degradation
|
||||
*
|
||||
* @param blocks - Array of content blocks
|
||||
* @returns Processed array with invalid thinking blocks handled
|
||||
*/
|
||||
export function processThinkingBlocks(blocks: ContentBlock[]): ContentBlock[] {
|
||||
const result: ContentBlock[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
const processed = gracefulDegradeThinking(block);
|
||||
if (processed !== null) {
|
||||
result.push(processed);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import { transformClaudeRequestIn } from '../../../lib/antigravity/ClaudeRequest
|
||||
import { transformResponse } from '../../../lib/antigravity/ClaudeResponseMapper';
|
||||
import { StreamingState, PartProcessor } from '../../../lib/antigravity/ClaudeStreamingMapper';
|
||||
import { ClaudeRequest } from '../../../lib/antigravity/types';
|
||||
import { calculateRetryDelay, sleep } from '../../../lib/antigravity/retry-utils';
|
||||
import {
|
||||
classifyStreamError,
|
||||
formatErrorForSSE,
|
||||
} from '../../../lib/antigravity/stream-error-utils';
|
||||
import {
|
||||
OpenAIChatRequest,
|
||||
AnthropicChatRequest,
|
||||
@@ -45,6 +50,13 @@ export class ProxyService {
|
||||
const maxRetries = 3;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
// Fix #6: Add jitter delay for retries (skip first attempt)
|
||||
if (i > 0) {
|
||||
const delay = calculateRetryDelay(i - 1);
|
||||
this.logger.log(`Retry attempt ${i + 1}/${maxRetries}, waiting ${delay}ms (jittered)`);
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
const token = await this.tokenManager.getNextToken();
|
||||
if (!token) {
|
||||
throw new Error('No available accounts');
|
||||
@@ -172,7 +184,11 @@ export class ProxyService {
|
||||
let lastFinishReason: string | undefined;
|
||||
let lastUsageMetadata: any | undefined;
|
||||
|
||||
// Fix #1: Track if we received any valid data
|
||||
let receivedData = false;
|
||||
|
||||
upstreamStream.on('data', (chunk: Buffer) => {
|
||||
receivedData = true; // Mark that we got data
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
@@ -205,21 +221,40 @@ export class ProxyService {
|
||||
const chunks = processor.process(part as any);
|
||||
chunks.forEach((c) => subscriber.next(c));
|
||||
}
|
||||
|
||||
// Reset error state on successful parse
|
||||
state.resetErrorState();
|
||||
} catch (e) {
|
||||
// Fix #3: Use handleParseError for graceful degradation
|
||||
this.logger.error('Stream parse error', e);
|
||||
const errorChunks = state.handleParseError(dataStr);
|
||||
errorChunks.forEach((c) => subscriber.next(c));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
upstreamStream.on('end', () => {
|
||||
// Fix #1: Check for empty stream
|
||||
if (!receivedData) {
|
||||
this.logger.warn('Empty response stream detected');
|
||||
subscriber.error(new Error('Empty response stream'));
|
||||
return;
|
||||
}
|
||||
|
||||
const finishChunks = state.emitFinish(lastFinishReason, lastUsageMetadata);
|
||||
finishChunks.forEach((c) => subscriber.next(c));
|
||||
subscriber.complete();
|
||||
});
|
||||
|
||||
upstreamStream.on('error', (err: any) => {
|
||||
// Convert to clean Error to avoid circular reference issues (socket objects)
|
||||
const cleanError = err instanceof Error ? new Error(err.message) : new Error(String(err));
|
||||
// Fix #2: Classify error and send friendly message
|
||||
const cleanError = err instanceof Error ? err : new Error(String(err));
|
||||
const { type, message } = classifyStreamError(cleanError);
|
||||
|
||||
this.logger.error(`Stream error: ${type} - ${cleanError.message}`);
|
||||
|
||||
// Send SSE error event before closing
|
||||
subscriber.next(formatErrorForSSE(type, message));
|
||||
subscriber.error(cleanError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Mock dependencies
|
||||
const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn() };
|
||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||
|
||||
// Subclass to access private method
|
||||
class TestableProxyService extends ProxyService {
|
||||
constructor() {
|
||||
super(mockTokenManager as any, mockGeminiClient as any);
|
||||
}
|
||||
|
||||
public testProcessStream(stream: any, model: string = 'model'): Observable<string> {
|
||||
// Access private method using type assertion
|
||||
return (this as any).processAnthropicInternalStream(stream, model);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProxyService Empty Stream Retry Logic', () => {
|
||||
it('should emit error when stream ends without data', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
|
||||
const resultObservable = service.testProcessStream(stream);
|
||||
|
||||
let errorReceived: Error | undefined;
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resultObservable.subscribe({
|
||||
next: () => {},
|
||||
error: (err) => {
|
||||
errorReceived = err;
|
||||
resolve();
|
||||
},
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate empty stream: straight to end
|
||||
setTimeout(() => stream.emit('end'), 10);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(errorReceived).toBeDefined();
|
||||
expect(errorReceived?.message).toBe('Empty response stream');
|
||||
});
|
||||
|
||||
it('should NOT emit error when stream has data', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
|
||||
const resultObservable = service.testProcessStream(stream);
|
||||
|
||||
let errorReceived: Error | undefined;
|
||||
const receivedChunks: string[] = [];
|
||||
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resultObservable.subscribe({
|
||||
next: (c) => receivedChunks.push(c),
|
||||
error: (err) => {
|
||||
errorReceived = err;
|
||||
resolve();
|
||||
},
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate valid data stream
|
||||
setTimeout(() => {
|
||||
const validJson = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'hello' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
stream.emit('data', Buffer.from(`data: ${validJson}\n\n`));
|
||||
stream.emit('end');
|
||||
}, 10);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(errorReceived).toBeUndefined();
|
||||
// It should produce chunks (though exact number depends on mapper logic, at least it shouldn't error)
|
||||
// Actually our mapper might produce "message_start", "content_block_start" etc.
|
||||
// We just care that it didn't error with "Empty response stream"
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyJitter, calculateRetryDelay } from '../../lib/antigravity/retry-utils';
|
||||
|
||||
describe('applyJitter', () => {
|
||||
it('should return values within ±20% range', () => {
|
||||
const baseDelay = 1000;
|
||||
const results: number[] = [];
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
results.push(applyJitter(baseDelay));
|
||||
}
|
||||
|
||||
// All values should be within 800-1200 range (±20%)
|
||||
results.forEach((r) => {
|
||||
expect(r).toBeGreaterThanOrEqual(800);
|
||||
expect(r).toBeLessThanOrEqual(1200);
|
||||
});
|
||||
});
|
||||
|
||||
it('should produce different values (randomness)', () => {
|
||||
const results = new Set<number>();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
results.add(applyJitter(1000));
|
||||
}
|
||||
// 20 calls should produce multiple distinct values
|
||||
expect(results.size).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it('should guarantee minimum value of 1 for small inputs', () => {
|
||||
expect(applyJitter(1)).toBeGreaterThanOrEqual(1);
|
||||
expect(applyJitter(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateRetryDelay', () => {
|
||||
it('should implement exponential backoff', () => {
|
||||
// First attempt (index 0): 1s ± 20% = 800-1200ms
|
||||
const delay0 = calculateRetryDelay(0);
|
||||
expect(delay0).toBeGreaterThanOrEqual(800);
|
||||
expect(delay0).toBeLessThanOrEqual(1200);
|
||||
|
||||
// Second attempt (index 1): 2s ± 20% = 1600-2400ms
|
||||
const delay1 = calculateRetryDelay(1);
|
||||
expect(delay1).toBeGreaterThanOrEqual(1600);
|
||||
expect(delay1).toBeLessThanOrEqual(2400);
|
||||
});
|
||||
|
||||
it('should have maximum delay limit', () => {
|
||||
// Even with high retry count, should not exceed 30s + 20% jitter = 36s
|
||||
const delay = calculateRetryDelay(10);
|
||||
expect(delay).toBeLessThanOrEqual(36000);
|
||||
});
|
||||
|
||||
it('should accept custom base delay', () => {
|
||||
const delay = calculateRetryDelay(0, 500);
|
||||
expect(delay).toBeGreaterThanOrEqual(400); // 500 - 20%
|
||||
expect(delay).toBeLessThanOrEqual(600); // 500 + 20%
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decodeSignature } from '../../lib/antigravity/signature-utils';
|
||||
|
||||
describe('decodeSignature', () => {
|
||||
describe('Base64 decoding', () => {
|
||||
it('should correctly decode valid Base64 signature', () => {
|
||||
const original = 'valid-thinking-signature-12345';
|
||||
const base64 = Buffer.from(original).toString('base64');
|
||||
expect(decodeSignature(base64)).toBe(original);
|
||||
});
|
||||
|
||||
it('should decode signature with special characters', () => {
|
||||
const original = 'sig+with/special=chars==';
|
||||
const base64 = Buffer.from(original).toString('base64');
|
||||
expect(decodeSignature(base64)).toBe(original);
|
||||
});
|
||||
|
||||
it('should decode UTF-8 Chinese signature', () => {
|
||||
const original = '思考签名-测试';
|
||||
const base64 = Buffer.from(original).toString('base64');
|
||||
expect(decodeSignature(base64)).toBe(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-Base64 passthrough', () => {
|
||||
it('should return non-Base64 strings as-is', () => {
|
||||
expect(decodeSignature('plain-text-signature')).toBe('plain-text-signature');
|
||||
});
|
||||
|
||||
it('should handle strings with invalid Base64 characters', () => {
|
||||
expect(decodeSignature('not!valid@base64#')).toBe('not!valid@base64#');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty value handling', () => {
|
||||
it('should return undefined for null input', () => {
|
||||
expect(decodeSignature(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
expect(decodeSignature(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty string', () => {
|
||||
expect(decodeSignature('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyStreamError, formatErrorForSSE } from '../../lib/antigravity/stream-error-utils';
|
||||
|
||||
describe('classifyStreamError', () => {
|
||||
it('should identify timeout errors', () => {
|
||||
const error = new Error('timeout of 30000ms exceeded');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('timeout_error');
|
||||
expect(message).toContain('timed out');
|
||||
});
|
||||
|
||||
it('should identify connection errors', () => {
|
||||
const error = new Error('connect ECONNREFUSED 127.0.0.1:8080');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('connection_error');
|
||||
expect(message).toContain('connect');
|
||||
});
|
||||
|
||||
it('should identify network DNS errors', () => {
|
||||
const error = new Error('getaddrinfo ENOTFOUND googleapis.com');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('network_error');
|
||||
expect(message).toContain('Network');
|
||||
});
|
||||
|
||||
it('should identify socket errors', () => {
|
||||
const error = new Error('socket hang up');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('stream_error');
|
||||
expect(message).toContain('interrupted');
|
||||
});
|
||||
|
||||
it('should return generic message for unknown errors', () => {
|
||||
const error = new Error('some random error');
|
||||
const { type, message } = classifyStreamError(error);
|
||||
expect(type).toBe('unknown_error');
|
||||
expect(message).toBeTruthy();
|
||||
expect(message).toContain('some random error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatErrorForSSE', () => {
|
||||
it('should format as Claude SSE error event', () => {
|
||||
const result = formatErrorForSSE('network_error', 'Network connection failed');
|
||||
expect(result).toContain('event: error');
|
||||
expect(result).toContain('"type":"error"');
|
||||
expect(result).toContain('network_error');
|
||||
expect(result).toContain('Network connection failed');
|
||||
});
|
||||
|
||||
it('should end with newlines', () => {
|
||||
const result = formatErrorForSSE('test', 'test');
|
||||
expect(result.endsWith('\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be valid SSE format', () => {
|
||||
const result = formatErrorForSSE('timeout_error', 'Request timed out');
|
||||
expect(result.startsWith('event: ')).toBe(true);
|
||||
expect(result).toContain('\ndata: ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { StreamingState } from '../../lib/antigravity/ClaudeStreamingMapper';
|
||||
|
||||
describe('StreamingState', () => {
|
||||
let state: StreamingState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = new StreamingState();
|
||||
});
|
||||
|
||||
describe('handleParseError', () => {
|
||||
it('should return empty array on first error', () => {
|
||||
const chunks = state.handleParseError('invalid json');
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
|
||||
it('should emit error event when error count exceeds 3', () => {
|
||||
// Simulate 4 parse errors
|
||||
state.handleParseError('error 1');
|
||||
state.handleParseError('error 2');
|
||||
state.handleParseError('error 3');
|
||||
const chunks = state.handleParseError('error 4');
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
expect(chunks[0]).toContain('network_error');
|
||||
expect(chunks[0]).toContain('Unstable network');
|
||||
});
|
||||
|
||||
it('should safely close active block on error', () => {
|
||||
// Start a text block first
|
||||
state.startBlock('Text', { type: 'text', text: '' });
|
||||
|
||||
const chunks = state.handleParseError('error during block');
|
||||
|
||||
// Should contain content_block_stop event
|
||||
expect(chunks.some((c) => c.includes('content_block_stop'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetErrorState', () => {
|
||||
it('should reset error counter', () => {
|
||||
state.handleParseError('error 1');
|
||||
state.handleParseError('error 2');
|
||||
state.resetErrorState();
|
||||
|
||||
// After reset, should start counting from 0
|
||||
const chunks = state.handleParseError('error after reset');
|
||||
expect(chunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getErrorCount', () => {
|
||||
it('should return current error count', () => {
|
||||
expect(state.getErrorCount()).toBe(0);
|
||||
state.handleParseError('error 1');
|
||||
expect(state.getErrorCount()).toBe(1);
|
||||
state.handleParseError('error 2');
|
||||
expect(state.getErrorCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
hasValidSignature,
|
||||
gracefulDegradeThinking,
|
||||
processThinkingBlocks,
|
||||
} from '../../lib/antigravity/thinking-utils';
|
||||
|
||||
describe('hasValidSignature', () => {
|
||||
it('should return true for empty thinking + valid signature', () => {
|
||||
const block = { type: 'thinking' as const, thinking: '', signature: 'valid-signature-long' };
|
||||
expect(hasValidSignature(block)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for content + long signature', () => {
|
||||
const block = {
|
||||
type: 'thinking' as const,
|
||||
thinking: 'some thought',
|
||||
signature: 'long-enough-signature',
|
||||
};
|
||||
expect(hasValidSignature(block)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for content + short signature', () => {
|
||||
const block = { type: 'thinking' as const, thinking: 'some thought', signature: 'short' };
|
||||
expect(hasValidSignature(block)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for content + no signature', () => {
|
||||
const block = { type: 'thinking' as const, thinking: 'some thought', signature: undefined };
|
||||
expect(hasValidSignature(block)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for non-thinking types', () => {
|
||||
const block = { type: 'text' as const, text: 'hello' };
|
||||
expect(hasValidSignature(block)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gracefulDegradeThinking', () => {
|
||||
it('should convert invalid signature thinking to text', () => {
|
||||
const block = { type: 'thinking' as const, thinking: 'important content', signature: 'bad' };
|
||||
const result = gracefulDegradeThinking(block);
|
||||
expect(result?.type).toBe('text');
|
||||
expect((result as any).text).toBe('important content');
|
||||
});
|
||||
|
||||
it('should keep valid thinking block unchanged', () => {
|
||||
const block = {
|
||||
type: 'thinking' as const,
|
||||
thinking: 'content',
|
||||
signature: 'valid-long-signature',
|
||||
};
|
||||
const result = gracefulDegradeThinking(block);
|
||||
expect(result?.type).toBe('thinking');
|
||||
});
|
||||
|
||||
it('should drop empty content + invalid signature block', () => {
|
||||
const block = { type: 'thinking' as const, thinking: '', signature: 'bad' };
|
||||
const result = gracefulDegradeThinking(block);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should keep empty content + valid signature trailing block', () => {
|
||||
const block = { type: 'thinking' as const, thinking: '', signature: 'any-valid-sig' };
|
||||
const result = gracefulDegradeThinking(block);
|
||||
expect(result?.type).toBe('thinking');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processThinkingBlocks', () => {
|
||||
it('should filter out invalid empty thinking blocks', () => {
|
||||
const blocks = [
|
||||
{ type: 'text' as const, text: 'hello' },
|
||||
{ type: 'thinking' as const, thinking: '', signature: 'bad' },
|
||||
{ type: 'text' as const, text: 'world' },
|
||||
];
|
||||
const result = processThinkingBlocks(blocks);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].type).toBe('text');
|
||||
expect(result[1].type).toBe('text');
|
||||
});
|
||||
|
||||
it('should convert content with invalid signature to text', () => {
|
||||
const blocks = [{ type: 'thinking' as const, thinking: 'keep me', signature: 'x' }];
|
||||
const result = processThinkingBlocks(blocks);
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
expect((result[0] as any).text).toBe('keep me');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user