@@ -2202,6 +2202,143 @@ describe('OpenAIContentConverter', () => {
22022202 } ) ;
22032203 } ) ;
22042204
2205+ it ( 'should ignore repeated cumulative reasoning_content chunks with no new suffix' , ( ) => {
2206+ // Mirrors the content-channel `should ignore repeated cumulative chunks
2207+ // with no new suffix` test: the reasoning channel uses a separate state
2208+ // object, so the exact-repeat entry path is exercised independently.
2209+ const ctx = withStreamParser ( ) ;
2210+ const reasoning =
2211+ 'The reasoning section also starts with enough text to pass.' ;
2212+ const emitted = [ reasoning , reasoning ] . map ( ( reasoning_content , index ) => {
2213+ const part = converter . convertOpenAIChunkToGemini (
2214+ {
2215+ object : 'chat.completion.chunk' ,
2216+ id : `chunk-reasoning-repeat-${ index } ` ,
2217+ created : 456 + index ,
2218+ choices : [
2219+ {
2220+ index : 0 ,
2221+ delta : { reasoning_content } ,
2222+ finish_reason : null ,
2223+ logprobs : null ,
2224+ } ,
2225+ ] ,
2226+ model : 'gpt-test' ,
2227+ } as unknown as OpenAI . Chat . ChatCompletionChunk ,
2228+ ctx ,
2229+ ) . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ;
2230+ return { text : part ?. text ?? '' , thought : part ?. thought ?? false } ;
2231+ } ) ;
2232+
2233+ // Chunk 1: emits as a thought part.
2234+ expect ( emitted [ 0 ] ) . toEqual ( { text : reasoning , thought : true } ) ;
2235+ // Chunk 2: exact repeat — enters cumulative mode, suppressed (no part).
2236+ expect ( emitted [ 1 ] ) . toEqual ( { text : '' , thought : false } ) ;
2237+ } ) ;
2238+
2239+ it ( 'should exit cumulative mode on reasoning_content channel when chunk does not match prior accumulated text' , ( ) => {
2240+ // Mirrors the content-channel `should exit cumulative mode` test against
2241+ // the reasoning channel's independent state.
2242+ const ctx = withStreamParser ( ) ;
2243+ const chunks = [
2244+ 'Step one of my reasoning is to gather inputs.' ,
2245+ 'Step one of my reasoning is to gather inputs.\nStep two: validate.' ,
2246+ 'Brand new unrelated reasoning.' ,
2247+ ] ;
2248+
2249+ const emitted = chunks . map ( ( reasoning_content , index ) => {
2250+ const part = converter . convertOpenAIChunkToGemini (
2251+ {
2252+ object : 'chat.completion.chunk' ,
2253+ id : `chunk-reasoning-exit-${ index } ` ,
2254+ created : 456 + index ,
2255+ choices : [
2256+ {
2257+ index : 0 ,
2258+ delta : { reasoning_content } ,
2259+ finish_reason : null ,
2260+ logprobs : null ,
2261+ } ,
2262+ ] ,
2263+ model : 'gpt-test' ,
2264+ } as unknown as OpenAI . Chat . ChatCompletionChunk ,
2265+ ctx ,
2266+ ) . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ;
2267+ return { text : part ?. text ?? '' , thought : part ?. thought ?? false } ;
2268+ } ) ;
2269+
2270+ // Chunk 1: initial passthrough.
2271+ expect ( emitted [ 0 ] ) . toEqual ( {
2272+ text : 'Step one of my reasoning is to gather inputs.' ,
2273+ thought : true ,
2274+ } ) ;
2275+ // Chunk 2: cumulative mode entered, emits suffix only.
2276+ expect ( emitted [ 1 ] ) . toEqual ( {
2277+ text : '\nStep two: validate.' ,
2278+ thought : true ,
2279+ } ) ;
2280+ // Chunk 3: NOT a prefix-extension — cumulative mode must exit and the
2281+ // new chunk must be appended verbatim (no silent loss).
2282+ expect ( emitted [ 2 ] ) . toEqual ( {
2283+ text : 'Brand new unrelated reasoning.' ,
2284+ thought : true ,
2285+ } ) ;
2286+ } ) ;
2287+
2288+ it ( 'should resume prefix detection on reasoning_content channel after exiting cumulative mode' , ( ) => {
2289+ // Mirrors the content-channel `should resume prefix detection cleanly
2290+ // after exiting cumulative mode` test. After the exit path resets the
2291+ // baseline to the new chunk, the reasoning channel must be able to
2292+ // re-enter cumulative mode on the next prefix-extending chunk.
2293+ const ctx = withStreamParser ( ) ;
2294+ const chunks = [
2295+ 'Step one of my reasoning is to gather inputs.' ,
2296+ 'Step one of my reasoning is to gather inputs.\nStep two: validate.' ,
2297+ 'Brand new unrelated reasoning.' ,
2298+ 'Brand new unrelated reasoning. And further reflection.' ,
2299+ ] ;
2300+
2301+ const emitted = chunks . map ( ( reasoning_content , index ) => {
2302+ const part = converter . convertOpenAIChunkToGemini (
2303+ {
2304+ object : 'chat.completion.chunk' ,
2305+ id : `chunk-reasoning-reentry-${ index } ` ,
2306+ created : 456 + index ,
2307+ choices : [
2308+ {
2309+ index : 0 ,
2310+ delta : { reasoning_content } ,
2311+ finish_reason : null ,
2312+ logprobs : null ,
2313+ } ,
2314+ ] ,
2315+ model : 'gpt-test' ,
2316+ } as unknown as OpenAI . Chat . ChatCompletionChunk ,
2317+ ctx ,
2318+ ) . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ;
2319+ return { text : part ?. text ?? '' , thought : part ?. thought ?? false } ;
2320+ } ) ;
2321+
2322+ expect ( emitted [ 0 ] ) . toEqual ( {
2323+ text : 'Step one of my reasoning is to gather inputs.' ,
2324+ thought : true ,
2325+ } ) ;
2326+ expect ( emitted [ 1 ] ) . toEqual ( {
2327+ text : '\nStep two: validate.' ,
2328+ thought : true ,
2329+ } ) ;
2330+ // Cumulative mode exits; fresh baseline = chunk 3.
2331+ expect ( emitted [ 2 ] ) . toEqual ( {
2332+ text : 'Brand new unrelated reasoning.' ,
2333+ thought : true ,
2334+ } ) ;
2335+ // Chunk 4 prefix-extends chunk 3 — re-enters cumulative, emits suffix only.
2336+ expect ( emitted [ 3 ] ) . toEqual ( {
2337+ text : ' And further reflection.' ,
2338+ thought : true ,
2339+ } ) ;
2340+ } ) ;
2341+
22052342 it ( 'should deduplicate interleaved reasoning_content and content channels independently' , ( ) => {
22062343 const ctx = withStreamParser ( ) ;
22072344 // reasoning_content and content each use a separate state object;
@@ -2245,6 +2382,184 @@ describe('OpenAIContentConverter', () => {
22452382 // Content chunk 2: cumulative extension of content channel
22462383 expect ( emitted [ 3 ] ) . toEqual ( [ { text : ' is the answer.' } ] ) ;
22472384 } ) ;
2385+
2386+ it ( 'should enter cumulative mode on exact 20-char repeat (at threshold)' , ( ) => {
2387+ const ctx = withStreamParser ( ) ;
2388+ // Exactly 20 chars — meets CUMULATIVE_DELTA_EXACT_REPEAT_MIN_LENGTH
2389+ const twentyChars = 'abcdefghij0123456789' ;
2390+ const chunks = [ twentyChars , twentyChars , twentyChars + ' and more' ] ;
2391+
2392+ const emitted = chunks . map (
2393+ ( content , index ) =>
2394+ converter . convertOpenAIChunkToGemini (
2395+ {
2396+ object : 'chat.completion.chunk' ,
2397+ id : `chunk-threshold20-${ index } ` ,
2398+ created : 456 + index ,
2399+ choices : [
2400+ {
2401+ index : 0 ,
2402+ delta : { content } ,
2403+ finish_reason : null ,
2404+ logprobs : null ,
2405+ } ,
2406+ ] ,
2407+ model : 'gpt-test' ,
2408+ } as unknown as OpenAI . Chat . ChatCompletionChunk ,
2409+ ctx ,
2410+ ) . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ?. text ?? '' ,
2411+ ) ;
2412+
2413+ // Chunk 1: initial passthrough
2414+ expect ( emitted [ 0 ] ) . toBe ( twentyChars ) ;
2415+ // Chunk 2: exact 20-char repeat — enters cumulative mode, suppressed
2416+ expect ( emitted [ 1 ] ) . toBe ( '' ) ;
2417+ // Chunk 3: cumulative extension — emits suffix only
2418+ expect ( emitted [ 2 ] ) . toBe ( ' and more' ) ;
2419+ } ) ;
2420+
2421+ it ( 'should pass through 19-char exact repeat without entering cumulative mode (below threshold)' , ( ) => {
2422+ const ctx = withStreamParser ( ) ;
2423+ // 19 chars — one short of CUMULATIVE_DELTA_EXACT_REPEAT_MIN_LENGTH
2424+ const nineteenChars = 'abcdefghij012345678' ;
2425+ const chunks = [ nineteenChars , nineteenChars , nineteenChars + ' extra' ] ;
2426+
2427+ const emitted = chunks . map (
2428+ ( content , index ) =>
2429+ converter . convertOpenAIChunkToGemini (
2430+ {
2431+ object : 'chat.completion.chunk' ,
2432+ id : `chunk-threshold19-${ index } ` ,
2433+ created : 456 + index ,
2434+ choices : [
2435+ {
2436+ index : 0 ,
2437+ delta : { content } ,
2438+ finish_reason : null ,
2439+ logprobs : null ,
2440+ } ,
2441+ ] ,
2442+ model : 'gpt-test' ,
2443+ } as unknown as OpenAI . Chat . ChatCompletionChunk ,
2444+ ctx ,
2445+ ) . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ?. text ?? '' ,
2446+ ) ;
2447+
2448+ // Chunk 1: initial passthrough
2449+ expect ( emitted [ 0 ] ) . toBe ( nineteenChars ) ;
2450+ // Chunk 2: 19-char repeat — below threshold, passes through unchanged
2451+ expect ( emitted [ 1 ] ) . toBe ( nineteenChars ) ;
2452+ // Chunk 3: prefix-extends prior — enters cumulative, emits suffix only
2453+ expect ( emitted [ 2 ] ) . toBe ( ' extra' ) ;
2454+ } ) ;
2455+
2456+ it ( 'should not enter cumulative mode after the detection window cap is reached on a non-cumulative stream' , ( ) => {
2457+ // After CUMULATIVE_DETECTION_WINDOW_BYTES (1024) of incremental
2458+ // emittedText growth, the baseline freezes and prefix/exact-repeat
2459+ // detection becomes unsafe. The early-return guard ensures that a chunk
2460+ // arriving after the cap is reached is passed through verbatim instead
2461+ // of being misclassified against a stale baseline.
2462+ const ctx = withStreamParser ( ) ;
2463+ // 100 incremental chunks of 20 chars = 2000 chars, well past the cap.
2464+ const incrementalChunks = Array . from (
2465+ { length : 100 } ,
2466+ ( _ , i ) => `chunk${ String ( i ) . padStart ( 3 , '0' ) } -payload__` ,
2467+ ) ;
2468+ // Trailing chunk that, by coincidence, starts with what *would* be the
2469+ // frozen 1024-char baseline. With the early-return guard, this MUST NOT
2470+ // enter cumulative mode and MUST emit the chunk verbatim.
2471+ const allEmitted = incrementalChunks . map (
2472+ ( content , index ) =>
2473+ converter . convertOpenAIChunkToGemini (
2474+ {
2475+ object : 'chat.completion.chunk' ,
2476+ id : `chunk-cap-${ index } ` ,
2477+ created : 456 + index ,
2478+ choices : [
2479+ {
2480+ index : 0 ,
2481+ delta : { content } ,
2482+ finish_reason : null ,
2483+ logprobs : null ,
2484+ } ,
2485+ ] ,
2486+ model : 'gpt-test' ,
2487+ } as unknown as OpenAI . Chat . ChatCompletionChunk ,
2488+ ctx ,
2489+ ) . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ?. text ?? '' ,
2490+ ) ;
2491+
2492+ // Every chunk should pass through verbatim — none should be
2493+ // misclassified as cumulative.
2494+ expect ( allEmitted ) . toEqual ( incrementalChunks ) ;
2495+ } ) ;
2496+
2497+ it ( 'should suppress cumulative rewind (provider re-sends shorter accumulated string)' , ( ) => {
2498+ const ctx = withStreamParser ( ) ;
2499+ // Scenario: provider sends Hello → Hello World (extension) → Hello (rewind) → Hello World! (extension again)
2500+ const chunks = [ 'Hello' , 'Hello World' , 'Hello' , 'Hello World!' ] ;
2501+
2502+ const emitted = chunks . map (
2503+ ( content , index ) =>
2504+ converter . convertOpenAIChunkToGemini (
2505+ {
2506+ object : 'chat.completion.chunk' ,
2507+ id : `chunk-rewind-${ index } ` ,
2508+ created : 456 + index ,
2509+ choices : [
2510+ {
2511+ index : 0 ,
2512+ delta : { content } ,
2513+ finish_reason : null ,
2514+ logprobs : null ,
2515+ } ,
2516+ ] ,
2517+ model : 'gpt-test' ,
2518+ } as unknown as OpenAI . Chat . ChatCompletionChunk ,
2519+ ctx ,
2520+ ) . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ?. text ?? '' ,
2521+ ) ;
2522+
2523+ // Chunk 1: initial passthrough
2524+ expect ( emitted [ 0 ] ) . toBe ( 'Hello' ) ;
2525+ // Chunk 2: prefix-extends 'Hello' → enters cumulative, emits suffix
2526+ expect ( emitted [ 1 ] ) . toBe ( ' World' ) ;
2527+ // Chunk 3: rewind — 'Hello' is a strict prefix of emitted 'Hello World' → suppressed
2528+ expect ( emitted [ 2 ] ) . toBe ( '' ) ;
2529+ // Chunk 4: extension resumes from 'Hello World' → emits '!'
2530+ expect ( emitted [ 3 ] ) . toBe ( '!' ) ;
2531+ } ) ;
2532+
2533+ it ( 'should handle a single chunk delta with both reasoning_content and content simultaneously' , ( ) => {
2534+ const ctx = withStreamParser ( ) ;
2535+ const part =
2536+ converter . convertOpenAIChunkToGemini (
2537+ {
2538+ object : 'chat.completion.chunk' ,
2539+ id : 'chunk-dual-1' ,
2540+ created : 456 ,
2541+ choices : [
2542+ {
2543+ index : 0 ,
2544+ delta : {
2545+ reasoning_content : 'I need to think.' ,
2546+ content : 'Here is my answer.' ,
2547+ } ,
2548+ finish_reason : null ,
2549+ logprobs : null ,
2550+ } ,
2551+ ] ,
2552+ model : 'gpt-test' ,
2553+ } as unknown as OpenAI . Chat . ChatCompletionChunk ,
2554+ ctx ,
2555+ ) . candidates ?. [ 0 ] ?. content ?. parts ?? [ ] ;
2556+
2557+ // Both channels should emit independently in the same response
2558+ const thoughtPart = part . find ( ( p ) => p . thought === true ) ;
2559+ const textPart = part . find ( ( p ) => ! p . thought ) ;
2560+ expect ( thoughtPart ?. text ) . toBe ( 'I need to think.' ) ;
2561+ expect ( textPart ?. text ) . toBe ( 'Here is my answer.' ) ;
2562+ } ) ;
22482563 } ) ;
22492564
22502565 describe ( 'OpenAI -> Gemini tagged thinking content' , ( ) => {
0 commit comments