Skip to content

Commit c4ac09f

Browse files
秦奇qwencoder
andcommitted
test(core): cover empty-cumulative + finish_reason path + rename duplicate
Two follow-ups from wenshao's 2026-05-09 review: 1. Add coverage for the `normalizedContent || choice.finish_reason` guard on the content path in cumulative mode. All prior cumulative tests use `finish_reason: null`, so the empty-normalized + non-null finish_reason path was untested. Establishes cumulative mode, then sends an exact- repeat final chunk with `finish_reason: 'stop'`; asserts the empty normalized delta does not emit spurious text but `finishReason` still propagates through to the candidate. 2. Rename the second cumulative-reasoning_content test (line 2162) to clarify its distinct scenario — multi-line accumulation where the emitted suffix itself begins with a newline. The previous name was confusingly similar to the test at line 2006 (single-line cumulative reasoning), making it unclear what each uniquely covered. 97 → 98 tests; lint clean. Generated with AI Co-authored-by: Qwen-Coder <[email protected]>
1 parent d132acb commit c4ac09f

1 file changed

Lines changed: 87 additions & 1 deletion

File tree

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

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2159,7 +2159,11 @@ describe('OpenAIContentConverter', () => {
21592159
expect(emitted[2]).toBe(' there, how are you today?');
21602160
});
21612161

2162-
it('should normalize cumulative reasoning_content deltas to suffixes', () => {
2162+
it('should normalize cumulative reasoning_content deltas across multi-line growth (newline-prefixed suffixes)', () => {
2163+
// Distinct from the single-line cumulative reasoning test above:
2164+
// this case grows the accumulated text across newline boundaries so the
2165+
// emitted suffixes themselves begin with '\n', exercising the slice
2166+
// arithmetic at the newline.
21632167
const ctx = withStreamParser();
21642168
const chunks = [
21652169
'Let me reason step by step.',
@@ -2530,6 +2534,88 @@ describe('OpenAIContentConverter', () => {
25302534
expect(emitted[3]).toBe('!');
25312535
});
25322536

2537+
it('should still call into convertOpenAITextToParts on finish_reason when the cumulative-mode normalized delta is empty', () => {
2538+
// Targets the `normalizedContent || choice.finish_reason` guard on the
2539+
// content path: in cumulative mode an exact-repeat final chunk yields a
2540+
// normalized delta of '' but must still flush buffered tagged-thinking
2541+
// content (and any other finish-time side effects) via
2542+
// convertOpenAITextToParts. The earlier cumulative tests all use
2543+
// `finish_reason: null`, so this exercises the empty-normalized +
2544+
// non-null finish_reason path in a cumulative context.
2545+
const ctx = withStreamParser();
2546+
// 1) Prefix-extension chunk pair establishes cumulative mode and primes
2547+
// `emittedText` so the next exact-repeat is the cumulative branch.
2548+
converter.convertOpenAIChunkToGemini(
2549+
{
2550+
object: 'chat.completion.chunk',
2551+
id: 'chunk-cum-empty-finish-0',
2552+
created: 456,
2553+
choices: [
2554+
{
2555+
index: 0,
2556+
delta: { content: 'Answer: forty-two' },
2557+
finish_reason: null,
2558+
logprobs: null,
2559+
},
2560+
],
2561+
model: 'gpt-test',
2562+
} as unknown as OpenAI.Chat.ChatCompletionChunk,
2563+
ctx,
2564+
);
2565+
converter.convertOpenAIChunkToGemini(
2566+
{
2567+
object: 'chat.completion.chunk',
2568+
id: 'chunk-cum-empty-finish-1',
2569+
created: 457,
2570+
choices: [
2571+
{
2572+
index: 0,
2573+
delta: { content: 'Answer: forty-two and more.' },
2574+
finish_reason: null,
2575+
logprobs: null,
2576+
},
2577+
],
2578+
model: 'gpt-test',
2579+
} as unknown as OpenAI.Chat.ChatCompletionChunk,
2580+
ctx,
2581+
);
2582+
2583+
// 2) Final chunk: re-sends the accumulated string verbatim along with
2584+
// `finish_reason: 'stop'`. The normalized delta is '' (cumulative
2585+
// suffix-of-self), but the finish_reason must still drive
2586+
// convertOpenAITextToParts.
2587+
const finalChunk = converter.convertOpenAIChunkToGemini(
2588+
{
2589+
object: 'chat.completion.chunk',
2590+
id: 'chunk-cum-empty-finish-2',
2591+
created: 458,
2592+
choices: [
2593+
{
2594+
index: 0,
2595+
delta: { content: 'Answer: forty-two and more.' },
2596+
finish_reason: 'stop',
2597+
logprobs: null,
2598+
},
2599+
],
2600+
model: 'gpt-test',
2601+
} as unknown as OpenAI.Chat.ChatCompletionChunk,
2602+
ctx,
2603+
);
2604+
2605+
// The cumulative-suppressed empty delta produces no text part, but
2606+
// because finish_reason is set, the converter still reaches the parts
2607+
// pipeline; on a clean (no buffered tag) state this yields parts: [].
2608+
// The crucial invariant: no exception thrown, finishReason propagates,
2609+
// and no spurious duplicate text emerges.
2610+
expect(finalChunk.candidates?.[0]?.finishReason).toBe('STOP');
2611+
const finalText =
2612+
finalChunk.candidates?.[0]?.content?.parts
2613+
?.filter((p) => 'text' in p)
2614+
?.map((p) => (p as { text: string }).text)
2615+
?.join('') ?? '';
2616+
expect(finalText).toBe('');
2617+
});
2618+
25332619
it('should handle a single chunk delta with both reasoning_content and content simultaneously', () => {
25342620
const ctx = withStreamParser();
25352621
const part =

0 commit comments

Comments
 (0)