mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
Fix(core): Do not retry if last chunk is empty with finishReason previous chunks are good (#7859)
This commit is contained in:
@@ -426,6 +426,58 @@ describe('GeminiChat', () => {
|
|||||||
})(),
|
})(),
|
||||||
).rejects.toThrow(EmptyStreamError);
|
).rejects.toThrow(EmptyStreamError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should succeed if the stream ends with an invalid part but has a finishReason and contained a valid part', async () => {
|
||||||
|
// 1. Mock a stream that sends a valid chunk, then an invalid one, but has a finish reason.
|
||||||
|
const streamWithInvalidEnd = (async function* () {
|
||||||
|
yield {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'Initial valid content...' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse;
|
||||||
|
// This second chunk is invalid, but the response has a finishReason.
|
||||||
|
yield {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: '' }], // Invalid part
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse;
|
||||||
|
})();
|
||||||
|
|
||||||
|
vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue(
|
||||||
|
streamWithInvalidEnd,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Action & Assert: The stream should complete without throwing an error.
|
||||||
|
const stream = await chat.sendMessageStream(
|
||||||
|
{ message: 'test message' },
|
||||||
|
'prompt-id-valid-then-invalid-end',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
(async () => {
|
||||||
|
for await (const _ of stream) {
|
||||||
|
/* consume stream */
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// 3. Verify history was recorded correctly with only the valid part.
|
||||||
|
const history = chat.getHistory();
|
||||||
|
expect(history.length).toBe(2); // user turn + model turn
|
||||||
|
const modelTurn = history[1]!;
|
||||||
|
expect(modelTurn?.parts?.length).toBe(1);
|
||||||
|
expect(modelTurn?.parts![0]!.text).toBe('Initial valid content...');
|
||||||
|
});
|
||||||
it('should not consolidate text into a part that also contains a functionCall', async () => {
|
it('should not consolidate text into a part that also contains a functionCall', async () => {
|
||||||
// 1. Mock the API to stream a malformed part followed by a valid text part.
|
// 1. Mock the API to stream a malformed part followed by a valid text part.
|
||||||
const multiChunkStream = (async function* () {
|
const multiChunkStream = (async function* () {
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ export class GeminiChat {
|
|||||||
): AsyncGenerator<GenerateContentResponse> {
|
): AsyncGenerator<GenerateContentResponse> {
|
||||||
const modelResponseParts: Part[] = [];
|
const modelResponseParts: Part[] = [];
|
||||||
let hasReceivedAnyChunk = false;
|
let hasReceivedAnyChunk = false;
|
||||||
|
let hasReceivedValidChunk = false;
|
||||||
let hasToolCall = false;
|
let hasToolCall = false;
|
||||||
let lastChunk: GenerateContentResponse | null = null;
|
let lastChunk: GenerateContentResponse | null = null;
|
||||||
let lastChunkIsInvalid = false;
|
let lastChunkIsInvalid = false;
|
||||||
@@ -588,6 +589,7 @@ export class GeminiChat {
|
|||||||
lastChunk = chunk;
|
lastChunk = chunk;
|
||||||
|
|
||||||
if (isValidResponse(chunk)) {
|
if (isValidResponse(chunk)) {
|
||||||
|
hasReceivedValidChunk = true;
|
||||||
lastChunkIsInvalid = false;
|
lastChunkIsInvalid = false;
|
||||||
const content = chunk.candidates?.[0]?.content;
|
const content = chunk.candidates?.[0]?.content;
|
||||||
if (content?.parts) {
|
if (content?.parts) {
|
||||||
@@ -614,15 +616,17 @@ export class GeminiChat {
|
|||||||
(candidate) => candidate.finishReason,
|
(candidate) => candidate.finishReason,
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- FIX: The entire validation block was restructured for clarity and correctness ---
|
|
||||||
// Stream validation logic: A stream is considered successful if:
|
// Stream validation logic: A stream is considered successful if:
|
||||||
// 1. There's a tool call (tool calls can end without explicit finish reasons), OR
|
// 1. There's a tool call (tool calls can end without explicit finish reasons), OR
|
||||||
// 2. Both conditions are met: last chunk is valid AND any candidate has a finish reason
|
// 2. There's a finish reason AND the last chunk is valid (or we haven't received any valid chunks)
|
||||||
//
|
//
|
||||||
// We throw an error only when there's no tool call AND either:
|
// We throw an error only when there's no tool call AND:
|
||||||
// - The last chunk is invalid, OR
|
// - No finish reason, OR
|
||||||
// - No candidate in the last chunk has a finish reason
|
// - Last chunk is invalid after receiving valid content
|
||||||
if (!hasToolCall && (lastChunkIsInvalid || !hasFinishReason)) {
|
if (
|
||||||
|
!hasToolCall &&
|
||||||
|
(!hasFinishReason || (lastChunkIsInvalid && !hasReceivedValidChunk))
|
||||||
|
) {
|
||||||
throw new EmptyStreamError(
|
throw new EmptyStreamError(
|
||||||
'Model stream ended with an invalid chunk or missing finish reason.',
|
'Model stream ended with an invalid chunk or missing finish reason.',
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user