Skip to content

Commit a07fbbe

Browse files
gemini-cli-robotmrcabbage972SandyTao520gemini-code-assist[bot]
authored
fix(patch): cherry-pick 0b6c020 to release/v0.9.0-preview.1-pr-10828 [CONFLICTS] (#10920)
Co-authored-by: Victor May <mayvic@google.com> Co-authored-by: Sandy Tao <sandytao520@icloud.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 0a30c20 commit a07fbbe

7 files changed

Lines changed: 228 additions & 2 deletions

File tree

packages/cli/src/ui/hooks/useGeminiStream.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,7 @@ export const useGeminiStream = (
724724
loopDetectedRef.current = true;
725725
break;
726726
case ServerGeminiEventType.Retry:
727+
case ServerGeminiEventType.InvalidStream:
727728
// Will add the missing logic later
728729
break;
729730
default: {

packages/core/src/config/config.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,31 @@ describe('Server Config (config.ts)', () => {
619619
});
620620
});
621621

622+
describe('ContinueOnFailedApiCall Configuration', () => {
623+
it('should default continueOnFailedApiCall to true when not provided', () => {
624+
const config = new Config(baseParams);
625+
expect(config.getContinueOnFailedApiCall()).toBe(true);
626+
});
627+
628+
it('should set continueOnFailedApiCall to true when provided as true', () => {
629+
const paramsWithContinueOnFailedApiCall: ConfigParameters = {
630+
...baseParams,
631+
continueOnFailedApiCall: true,
632+
};
633+
const config = new Config(paramsWithContinueOnFailedApiCall);
634+
expect(config.getContinueOnFailedApiCall()).toBe(true);
635+
});
636+
637+
it('should set continueOnFailedApiCall to false when explicitly provided as false', () => {
638+
const paramsWithContinueOnFailedApiCall: ConfigParameters = {
639+
...baseParams,
640+
continueOnFailedApiCall: false,
641+
};
642+
const config = new Config(paramsWithContinueOnFailedApiCall);
643+
expect(config.getContinueOnFailedApiCall()).toBe(false);
644+
});
645+
});
646+
622647
describe('createToolRegistry', () => {
623648
it('should register a tool if coreTools contains an argument-specific pattern', async () => {
624649
const params: ConfigParameters = {

packages/core/src/config/config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export interface ConfigParameters {
260260
useModelRouter?: boolean;
261261
enableMessageBusIntegration?: boolean;
262262
enableSubagents?: boolean;
263+
continueOnFailedApiCall?: boolean;
263264
}
264265

265266
export class Config {
@@ -353,6 +354,7 @@ export class Config {
353354
private readonly useModelRouter: boolean;
354355
private readonly enableMessageBusIntegration: boolean;
355356
private readonly enableSubagents: boolean;
357+
private readonly continueOnFailedApiCall: boolean;
356358

357359
constructor(params: ConfigParameters) {
358360
this.sessionId = params.sessionId;
@@ -443,6 +445,7 @@ export class Config {
443445
this.enableMessageBusIntegration =
444446
params.enableMessageBusIntegration ?? false;
445447
this.enableSubagents = params.enableSubagents ?? false;
448+
this.continueOnFailedApiCall = params.continueOnFailedApiCall ?? true;
446449
this.extensionManagement = params.extensionManagement ?? true;
447450
this.storage = new Storage(this.targetDir);
448451
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
@@ -941,6 +944,10 @@ export class Config {
941944
return this.skipNextSpeakerCheck;
942945
}
943946

947+
getContinueOnFailedApiCall(): boolean {
948+
return this.continueOnFailedApiCall;
949+
}
950+
944951
getShellExecutionConfig(): ShellExecutionConfig {
945952
return this.shellExecutionConfig;
946953
}

packages/core/src/core/client.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ describe('Gemini Client (client.ts)', () => {
315315
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
316316
getUseSmartEdit: vi.fn().mockReturnValue(false),
317317
getUseModelRouter: vi.fn().mockReturnValue(false),
318+
getContinueOnFailedApiCall: vi.fn(),
318319
getProjectRoot: vi.fn().mockReturnValue('/test/project/root'),
319320
storage: {
320321
getProjectTempDir: vi.fn().mockReturnValue('/test/temp'),
@@ -1288,6 +1289,9 @@ ${JSON.stringify(
12881289
});
12891290

12901291
it('should stop infinite loop after MAX_TURNS when nextSpeaker always returns model', async () => {
1292+
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
1293+
true,
1294+
);
12911295
// Get the mocked checkNextSpeaker function and configure it to trigger infinite loop
12921296
const { checkNextSpeaker } = await import(
12931297
'../utils/nextSpeakerChecker.js'
@@ -1677,6 +1681,131 @@ ${JSON.stringify(
16771681
});
16781682
});
16791683

1684+
it('should recursively call sendMessageStream with "Please continue." when InvalidStream event is received', async () => {
1685+
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
1686+
true,
1687+
);
1688+
// Arrange
1689+
const mockStream1 = (async function* () {
1690+
yield { type: GeminiEventType.InvalidStream };
1691+
})();
1692+
const mockStream2 = (async function* () {
1693+
yield { type: GeminiEventType.Content, value: 'Continued content' };
1694+
})();
1695+
1696+
mockTurnRunFn
1697+
.mockReturnValueOnce(mockStream1)
1698+
.mockReturnValueOnce(mockStream2);
1699+
1700+
const mockChat: Partial<GeminiChat> = {
1701+
addHistory: vi.fn(),
1702+
getHistory: vi.fn().mockReturnValue([]),
1703+
};
1704+
client['chat'] = mockChat as GeminiChat;
1705+
1706+
const initialRequest = [{ text: 'Hi' }];
1707+
const promptId = 'prompt-id-invalid-stream';
1708+
const signal = new AbortController().signal;
1709+
1710+
// Act
1711+
const stream = client.sendMessageStream(initialRequest, signal, promptId);
1712+
const events = await fromAsync(stream);
1713+
1714+
// Assert
1715+
expect(events).toEqual([
1716+
{ type: GeminiEventType.InvalidStream },
1717+
{ type: GeminiEventType.Content, value: 'Continued content' },
1718+
]);
1719+
1720+
// Verify that turn.run was called twice
1721+
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
1722+
1723+
// First call with original request
1724+
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
1725+
1,
1726+
expect.any(String),
1727+
initialRequest,
1728+
expect.any(Object),
1729+
);
1730+
1731+
// Second call with "Please continue."
1732+
expect(mockTurnRunFn).toHaveBeenNthCalledWith(
1733+
2,
1734+
expect.any(String),
1735+
[{ text: 'System: Please continue.' }],
1736+
expect.any(Object),
1737+
);
1738+
});
1739+
1740+
it('should not recursively call sendMessageStream with "Please continue." when InvalidStream event is received and flag is false', async () => {
1741+
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
1742+
false,
1743+
);
1744+
// Arrange
1745+
const mockStream1 = (async function* () {
1746+
yield { type: GeminiEventType.InvalidStream };
1747+
})();
1748+
1749+
mockTurnRunFn.mockReturnValueOnce(mockStream1);
1750+
1751+
const mockChat: Partial<GeminiChat> = {
1752+
addHistory: vi.fn(),
1753+
getHistory: vi.fn().mockReturnValue([]),
1754+
};
1755+
client['chat'] = mockChat as GeminiChat;
1756+
1757+
const initialRequest = [{ text: 'Hi' }];
1758+
const promptId = 'prompt-id-invalid-stream';
1759+
const signal = new AbortController().signal;
1760+
1761+
// Act
1762+
const stream = client.sendMessageStream(initialRequest, signal, promptId);
1763+
const events = await fromAsync(stream);
1764+
1765+
// Assert
1766+
expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]);
1767+
1768+
// Verify that turn.run was called only once
1769+
expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
1770+
});
1771+
1772+
it('should stop recursing after one retry when InvalidStream events are repeatedly received', async () => {
1773+
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
1774+
true,
1775+
);
1776+
// Arrange
1777+
// Always return a new invalid stream
1778+
mockTurnRunFn.mockImplementation(() =>
1779+
(async function* () {
1780+
yield { type: GeminiEventType.InvalidStream };
1781+
})(),
1782+
);
1783+
1784+
const mockChat: Partial<GeminiChat> = {
1785+
addHistory: vi.fn(),
1786+
getHistory: vi.fn().mockReturnValue([]),
1787+
};
1788+
client['chat'] = mockChat as GeminiChat;
1789+
1790+
const initialRequest = [{ text: 'Hi' }];
1791+
const promptId = 'prompt-id-infinite-invalid-stream';
1792+
const signal = new AbortController().signal;
1793+
1794+
// Act
1795+
const stream = client.sendMessageStream(initialRequest, signal, promptId);
1796+
const events = await fromAsync(stream);
1797+
1798+
// Assert
1799+
// We expect 2 InvalidStream events (original + 1 retry)
1800+
expect(events.length).toBe(2);
1801+
expect(
1802+
events.every((e) => e.type === GeminiEventType.InvalidStream),
1803+
).toBe(true);
1804+
1805+
// Verify that turn.run was called twice
1806+
expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
1807+
});
1808+
16801809
describe('Editor context delta', () => {
16811810
const mockStream = (async function* () {
16821811
yield { type: 'content', value: 'Hello' };

packages/core/src/core/client.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ import { LoopDetectionService } from '../services/loopDetectionService.js';
4040
import { ideContextStore } from '../ide/ideContext.js';
4141
import {
4242
logChatCompression,
43+
logContentRetryFailure,
4344
logNextSpeakerCheck,
4445
} from '../telemetry/loggers.js';
4546
import {
47+
ContentRetryFailureEvent,
4648
makeChatCompressionEvent,
4749
NextSpeakerCheckEvent,
4850
} from '../telemetry/types.js';
@@ -463,6 +465,7 @@ My setup is complete. I will provide my first command in the next turn.
463465
signal: AbortSignal,
464466
prompt_id: string,
465467
turns: number = MAX_TURNS,
468+
isInvalidStreamRetry: boolean = false,
466469
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
467470
if (this.lastPromptId !== prompt_id) {
468471
this.loopDetector.reset(prompt_id);
@@ -554,6 +557,31 @@ My setup is complete. I will provide my first command in the next turn.
554557
return turn;
555558
}
556559
yield event;
560+
if (event.type === GeminiEventType.InvalidStream) {
561+
if (this.config.getContinueOnFailedApiCall()) {
562+
if (isInvalidStreamRetry) {
563+
// We already retried once, so stop here.
564+
logContentRetryFailure(
565+
this.config,
566+
new ContentRetryFailureEvent(
567+
4, // 2 initial + 2 after injections
568+
'FAILED_AFTER_PROMPT_INJECTION',
569+
modelToUse,
570+
),
571+
);
572+
return turn;
573+
}
574+
const nextRequest = [{ text: 'System: Please continue.' }];
575+
yield* this.sendMessageStream(
576+
nextRequest,
577+
signal,
578+
prompt_id,
579+
boundedTurns - 1,
580+
true, // Set isInvalidStreamRetry to true
581+
);
582+
return turn;
583+
}
584+
}
557585
if (event.type === GeminiEventType.Error) {
558586
return turn;
559587
}
@@ -591,6 +619,7 @@ My setup is complete. I will provide my first command in the next turn.
591619
signal,
592620
prompt_id,
593621
boundedTurns - 1,
622+
// isInvalidStreamRetry is false here, as this is a next speaker check
594623
);
595624
}
596625
}

packages/core/src/core/turn.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Turn, GeminiEventType } from './turn.js';
1313
import type { GenerateContentResponse, Part, Content } from '@google/genai';
1414
import { reportError } from '../utils/errorReporting.js';
1515
import type { GeminiChat } from './geminiChat.js';
16-
import { StreamEventType } from './geminiChat.js';
16+
import { InvalidStreamError, StreamEventType } from './geminiChat.js';
1717

1818
const mockSendMessageStream = vi.fn();
1919
const mockGetHistory = vi.fn();
@@ -223,6 +223,28 @@ describe('Turn', () => {
223223
expect(turn.getDebugResponses().length).toBe(1);
224224
});
225225

226+
it('should yield InvalidStream event if sendMessageStream throws InvalidStreamError', async () => {
227+
const error = new InvalidStreamError(
228+
'Test invalid stream',
229+
'NO_FINISH_REASON',
230+
);
231+
mockSendMessageStream.mockRejectedValue(error);
232+
const reqParts: Part[] = [{ text: 'Trigger invalid stream' }];
233+
234+
const events = [];
235+
for await (const event of turn.run(
236+
'test-model',
237+
reqParts,
238+
new AbortController().signal,
239+
)) {
240+
events.push(event);
241+
}
242+
243+
expect(events).toEqual([{ type: GeminiEventType.InvalidStream }]);
244+
expect(turn.getDebugResponses().length).toBe(0);
245+
expect(reportError).not.toHaveBeenCalled(); // Should not report as error
246+
});
247+
226248
it('should yield Error event and report if sendMessageStream throws', async () => {
227249
const error = new Error('API Error');
228250
mockSendMessageStream.mockRejectedValue(error);

packages/core/src/core/turn.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
toFriendlyError,
2828
} from '../utils/errors.js';
2929
import type { GeminiChat } from './geminiChat.js';
30+
import { InvalidStreamError } from './geminiChat.js';
3031
import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js';
3132
import { createUserContent } from '@google/genai';
3233

@@ -59,12 +60,18 @@ export enum GeminiEventType {
5960
LoopDetected = 'loop_detected',
6061
Citation = 'citation',
6162
Retry = 'retry',
63+
ContextWindowWillOverflow = 'context_window_will_overflow',
64+
InvalidStream = 'invalid_stream',
6265
}
6366

6467
export type ServerGeminiRetryEvent = {
6568
type: GeminiEventType.Retry;
6669
};
6770

71+
export type ServerGeminiInvalidStreamEvent = {
72+
type: GeminiEventType.InvalidStream;
73+
};
74+
6875
export interface StructuredError {
6976
message: string;
7077
status?: number;
@@ -193,7 +200,8 @@ export type ServerGeminiStreamEvent =
193200
| ServerGeminiToolCallRequestEvent
194201
| ServerGeminiToolCallResponseEvent
195202
| ServerGeminiUserCancelledEvent
196-
| ServerGeminiRetryEvent;
203+
| ServerGeminiRetryEvent
204+
| ServerGeminiInvalidStreamEvent;
197205

198206
// A turn manages the agentic loop turn within the server context.
199207
export class Turn {
@@ -302,6 +310,11 @@ export class Turn {
302310
return;
303311
}
304312

313+
if (e instanceof InvalidStreamError) {
314+
yield { type: GeminiEventType.InvalidStream };
315+
return;
316+
}
317+
305318
const error = toFriendlyError(e);
306319
if (error instanceof UnauthorizedError) {
307320
throw error;

0 commit comments

Comments
 (0)