Skip to content

Commit d132acb

Browse files
秦奇qwencoder
andcommitted
test(core): mirror cumulative-mode tests on reasoning channel + harden window cap
Adds reasoning-channel coverage requested in the most recent review of the cumulative-delta normalization fix: - exact-repeat entry on `reasoning_content` (mirrors the content-channel `should ignore repeated cumulative chunks` test) - cumulative-mode exit on `reasoning_content` when a chunk does not match the prior accumulated text (mirrors `should exit cumulative mode`) - prefix-detection re-entry on `reasoning_content` after the exit path resets the baseline (mirrors `should resume prefix detection cleanly`) Also lands the previously-staged converter.ts hardening from the 2026-05-08 review round and adds the missing detection-window cap test: - emit `debugLogger.debug` trace on the cumulative-rewind suppression branch so the third silent-suppression path has parity with the other two (was the only suppression path with no observability) - early-return when the non-cumulative `emittedText` baseline is frozen at `CUMULATIVE_DETECTION_WINDOW_BYTES` (1024); prevents prefix/exact-repeat checks from running against a stale baseline once the cap is reached - regression test: 2000 chars of incremental chunks past the 1024 cap all pass through verbatim (the cap holds; no late misclassification) Reasoning and content channels share `normalizeStreamingTextDelta` but maintain separate state objects, so the integration points differ; these tests guard against future refactors that accidentally couple them or break the per-channel exit/re-entry behavior. Generated with AI Co-authored-by: Qwen-Coder <[email protected]>
1 parent fc2492c commit d132acb

2 files changed

Lines changed: 325 additions & 0 deletions

File tree

packages/core/src/core/openaiContentGenerator/converter.test.ts

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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', () => {

packages/core/src/core/openaiContentGenerator/converter.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ function normalizeStreamingTextDelta(
9494
}
9595

9696
if (state.emittedText.startsWith(rawDelta)) {
97+
debugLogger.debug(
98+
`normalizeStreamingTextDelta: cumulative rewind suppression (emitted=${state.emittedText.length}b, chunk=${rawDelta.length}b)`,
99+
);
97100
return '';
98101
}
99102

@@ -106,6 +109,13 @@ function normalizeStreamingTextDelta(
106109
return rawDelta;
107110
}
108111

112+
if (
113+
!state.cumulativeMode &&
114+
state.emittedText.length >= CUMULATIVE_DETECTION_WINDOW_BYTES
115+
) {
116+
return rawDelta;
117+
}
118+
109119
if (
110120
rawDelta.startsWith(state.emittedText) &&
111121
rawDelta.length > state.emittedText.length

0 commit comments

Comments
 (0)