Compare commits

...

9 Commits

Author SHA1 Message Date
jacob314 d78db4f514 fix: address PR feedback for slash commands and background task tracking 2026-05-18 14:27:13 -07:00
jacob314 3e1be515e5 fix: address PR feedback for slash commands and test cleanup 2026-05-18 11:54:08 -07:00
jacob314 469f5b7a68 fix: address PR feedback and background task lifecycle bugs
Addresses high-priority feedback from the automated code review tool regarding the logic error and inconsistency in `useAgentStream.ts` and `useExecutionLifecycle.ts`.

- `useAgentStream.ts`: Fixed `schedule_tool` fall-through by returning early if the command type is not `submit_prompt`. Prevented history duplication by only adding items not already handled by the slash command processor. Ensured the original raw string is logged.
- `useExecutionLifecycle.ts`: Removed `isActive` guards from the unmount cleanup effect and the `ExecutionLifecycleService.onBackground` listener to prevent background tasks from being incorrectly unsubscribed or missed when switching between Gemini and Agent streams.
- Updated `useAgentStream.test.tsx` to reflect the fixed history logic.
2026-05-18 09:48:40 -07:00
jacob314 b2946b1052 Checkpoint in support /tool commands. 2026-05-18 08:48:53 -07:00
Adam Weidman 84423e6ea1 fix(core): enforce compile-time exhaustiveness in content-utils (#27207) 2026-05-18 15:16:49 +00:00
Adam Weidman 5611ff40e7 feat(core): add adk.agentSessionSubagentEnabled flag (#26947) 2026-05-17 17:38:34 +00:00
David Pierce 77e65c0db5 fix(core): use hasAccessToPreview for auto model resolution and fix disappearing models (#27112) 2026-05-15 17:26:59 +00:00
Anish Sabharwal b36788eb2a fix(core): add aliases and thinking config for gemini-3.1 models (#27007) 2026-05-15 16:29:15 +00:00
PROTHAM d32c9b77df Fix/web fetch ctrl c abort (#24320)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-15 16:26:03 +00:00
18 changed files with 441 additions and 49 deletions
+24
View File
@@ -550,6 +550,24 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"gemini-3.1-pro-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview"
}
},
"gemini-3.1-pro-preview-customtools": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview-customtools"
}
},
"gemini-3.1-flash-lite-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
@@ -1836,6 +1854,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionSubagentEnabled`** (boolean):
- **Description:** Route subagent invocations through the AgentSession
protocol instead of legacy executors.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
@@ -571,6 +571,18 @@ describe('SettingsSchema', () => {
expect(agentSessionNoninteractiveEnabled.description).toBe(
'Enable non-interactive agent sessions.',
);
const agentSessionSubagentEnabled =
adk.properties.agentSessionSubagentEnabled;
expect(agentSessionSubagentEnabled).toBeDefined();
expect(agentSessionSubagentEnabled.type).toBe('boolean');
expect(agentSessionSubagentEnabled.category).toBe('Experimental');
expect(agentSessionSubagentEnabled.default).toBe(false);
expect(agentSessionSubagentEnabled.requiresRestart).toBe(true);
expect(agentSessionSubagentEnabled.showInDialog).toBe(false);
expect(agentSessionSubagentEnabled.description).toBe(
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
);
});
});
+10
View File
@@ -2194,6 +2194,16 @@ const SETTINGS_SCHEMA = {
'Enable the agent session implementation for the interactive CLI.',
showInDialog: false,
},
agentSessionSubagentEnabled: {
type: 'boolean',
label: 'Agent Session Subagent Enabled',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
showInDialog: false,
},
},
},
enableAgents: {
+1
View File
@@ -1185,6 +1185,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
useAgentStream({
agent: streamAgent,
addItem: historyManager.addItem,
handleSlashCommand,
onCancelSubmit,
isShellFocused: embeddedShellFocused,
logger,
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import type { LegacyAgentProtocol } from '@google/gemini-cli-core';
import type { AgentProtocol } from '@google/gemini-cli-core';
import { renderHookWithProviders } from '../../test-utils/render.js';
// --- MOCKS ---
const mockLegacyAgentProtocol = vi.hoisted(() => ({
const mockAgentProtocol = vi.hoisted(() => ({
send: vi.fn().mockResolvedValue({ streamId: 'test-stream-id' }),
subscribe: vi.fn().mockReturnValue(() => {}),
abort: vi.fn().mockResolvedValue(undefined),
@@ -40,24 +40,31 @@ describe('useAgentStream', () => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should initialize on mount', async () => {
await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
agent: mockAgentProtocol as unknown as AgentProtocol,
addItem: mockAddItem,
handleSlashCommand: vi.fn().mockResolvedValue(false),
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
expect(mockLegacyAgentProtocol.subscribe).toHaveBeenCalled();
expect(mockAgentProtocol.subscribe).toHaveBeenCalled();
});
it('should call agent.send when submitQuery is called', async () => {
const mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
agent: mockAgentProtocol as unknown as AgentProtocol,
addItem: mockAddItem,
handleSlashCommand: mockHandleSlashCommand,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
@@ -67,7 +74,7 @@ describe('useAgentStream', () => {
await result.current.submitQuery('hello');
});
expect(mockLegacyAgentProtocol.send).toHaveBeenCalledWith({
expect(mockAgentProtocol.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'hello' }] },
});
expect(mockAddItem).toHaveBeenCalledWith(
@@ -76,17 +83,70 @@ describe('useAgentStream', () => {
);
});
it('should update streamingState based on agent_start and agent_end events', async () => {
it('should intercept slash commands and not call agent.send if handled', async () => {
const mockHandleSlashCommand = vi
.fn()
.mockResolvedValue({ type: 'handled' });
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
agent: mockAgentProtocol as unknown as AgentProtocol,
addItem: mockAddItem,
handleSlashCommand: mockHandleSlashCommand,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
await act(async () => {
await result.current.submitQuery('/about');
});
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/about');
expect(mockAgentProtocol.send).not.toHaveBeenCalled();
});
it('should intercept slash commands and call agent.send with new content if submit_prompt', async () => {
const mockHandleSlashCommand = vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: 'modified prompt',
});
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockAgentProtocol as unknown as AgentProtocol,
addItem: mockAddItem,
handleSlashCommand: mockHandleSlashCommand,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
await act(async () => {
await result.current.submitQuery('/mcp-prompt');
});
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/mcp-prompt');
expect(mockAgentProtocol.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'modified prompt' }] },
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.USER, text: '/mcp-prompt' }),
expect.any(Number),
);
});
it('should update streamingState based on agent_start and agent_end events', async () => {
const mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockAgentProtocol as unknown as AgentProtocol,
addItem: mockAddItem,
handleSlashCommand: mockHandleSlashCommand,
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockAgentProtocol.subscribe).mock
.calls[0][0];
expect(result.current.streamingState).toBe(StreamingState.Idle);
@@ -116,14 +176,15 @@ describe('useAgentStream', () => {
it('should accumulate text content and update pendingHistoryItems', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
agent: mockAgentProtocol as unknown as AgentProtocol,
addItem: mockAddItem,
handleSlashCommand: vi.fn().mockResolvedValue(false),
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
const eventHandler = vi.mocked(mockAgentProtocol.subscribe).mock
.calls[0][0];
act(() => {
@@ -160,14 +221,15 @@ describe('useAgentStream', () => {
it('should process thought events and update thought state', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
agent: mockAgentProtocol as unknown as AgentProtocol,
addItem: mockAddItem,
handleSlashCommand: vi.fn().mockResolvedValue(false),
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
);
const eventHandler = vi.mocked(mockLegacyAgentProtocol.subscribe).mock
const eventHandler = vi.mocked(mockAgentProtocol.subscribe).mock
.calls[0][0];
act(() => {
@@ -190,8 +252,9 @@ describe('useAgentStream', () => {
it('should call agent.abort when cancelOngoingRequest is called', async () => {
const { result } = await renderHookWithProviders(() =>
useAgentStream({
agent: mockLegacyAgentProtocol as unknown as LegacyAgentProtocol,
agent: mockAgentProtocol as unknown as AgentProtocol,
addItem: mockAddItem,
handleSlashCommand: vi.fn().mockResolvedValue(false),
onCancelSubmit: mockOnCancelSubmit,
isShellFocused: false,
}),
@@ -201,7 +264,7 @@ describe('useAgentStream', () => {
await result.current.cancelOngoingRequest();
});
expect(mockLegacyAgentProtocol.abort).toHaveBeenCalled();
expect(mockAgentProtocol.abort).toHaveBeenCalled();
expect(mockOnCancelSubmit).toHaveBeenCalledWith(false, true);
});
});
+60 -10
View File
@@ -5,12 +5,14 @@
*/
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { type PartListUnion } from '@google/genai';
import {
getErrorMessage,
MessageSenderType,
debugLogger,
geminiPartsToContentParts,
displayContentToString,
partToString,
parseThought,
CoreToolCallStatus,
type ApprovalMode,
@@ -20,15 +22,16 @@ import {
type AgentEvent,
type AgentProtocol,
type Logger,
type Part,
} from '@google/gemini-cli-core';
import type {
HistoryItemWithoutId,
LoopDetectionConfirmationRequest,
IndividualToolCallDisplay,
HistoryItemToolDisplayGroup,
SlashCommandProcessorResult,
} from '../types.js';
import { StreamingState, MessageType } from '../types.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
import { type BackgroundTask } from './useExecutionLifecycle.js';
@@ -41,6 +44,9 @@ import { useKeypress } from './useKeypress.js';
export interface UseAgentStreamOptions {
agent?: AgentProtocol;
addItem: UseHistoryManagerReturn['addItem'];
handleSlashCommand: (
cmd: string,
) => Promise<SlashCommandProcessorResult | false>;
onCancelSubmit: (
shouldRestorePrompt?: boolean,
clearBuffer?: boolean,
@@ -56,6 +62,7 @@ export interface UseAgentStreamOptions {
export const useAgentStream = ({
agent,
addItem,
handleSlashCommand,
onCancelSubmit,
isShellFocused,
logger,
@@ -333,8 +340,8 @@ export const useAgentStream = ({
useKeypress(
(key) => {
if (key.name === 'escape' && !isShellFocused) {
void cancelOngoingRequest(false);
if (key.ctrl && key.name === 'c') {
void cancelOngoingRequest();
return true;
}
return false;
@@ -348,7 +355,7 @@ export const useAgentStream = ({
const submitQuery = useCallback(
async (
query: Part[] | string,
query: PartListUnion,
options?: { isContinuation: boolean },
_prompt_id?: string,
) => {
@@ -360,16 +367,59 @@ export const useAgentStream = ({
geminiMessageBufferRef.current = '';
let localQuery: PartListUnion = query;
if (!options?.isContinuation) {
if (typeof query === 'string') {
addItem({ type: MessageType.USER, text: query }, timestamp);
void logger?.logMessage(MessageSenderType.USER, query);
let shouldAddToHistory = true;
if (typeof localQuery === 'string') {
const trimmedQuery = localQuery.trim();
void logger?.logMessage(MessageSenderType.USER, trimmedQuery);
if (isSlashCommand(trimmedQuery)) {
const slashResult = await handleSlashCommand(trimmedQuery);
if (slashResult) {
if (slashResult.type === 'submit_prompt') {
localQuery = slashResult.content;
} else if (slashResult.type === 'schedule_tool') {
addItem(
{
type: MessageType.ERROR,
text: `The /${slashResult.toolName} command is not yet supported in Agent mode.`,
},
timestamp,
);
return;
} else {
// 'handled' or other types that don't need LLM submission
shouldAddToHistory = false;
return;
}
}
}
}
if (shouldAddToHistory) {
const originalQueryText =
typeof query === 'string' ? query : partToString(query);
addItem(
{ type: MessageType.USER, text: originalQueryText },
timestamp,
);
if (typeof localQuery !== 'string') {
void logger?.logMessage(
MessageSenderType.USER,
partToString(localQuery),
);
}
}
startNewPrompt();
}
const parts = geminiPartsToContentParts(
typeof query === 'string' ? [{ text: query }] : query,
(Array.isArray(localQuery) ? localQuery : [localQuery]).map((p) =>
typeof p === 'string' ? { text: p } : p,
),
);
try {
@@ -384,9 +434,8 @@ export const useAgentStream = ({
);
}
},
[agent, addItem, logger, startNewPrompt],
[agent, addItem, logger, startNewPrompt, handleSlashCommand],
);
useEffect(() => {
if (trackedTools.length > 0) {
const isNewBatch = !trackedTools.some((tc) =>
@@ -397,6 +446,7 @@ export const useAgentStream = ({
setIsFirstToolInGroup(true);
}
} else if (streamingState === StreamingState.Idle) {
// Clear when idle to be ready for next turn
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
}
+19 -5
View File
@@ -387,6 +387,11 @@ export const useGeminiStream = (
[setIsResponding],
);
const streamingState = useMemo(
() => calculateStreamingState(isResponding, toolCalls),
[isResponding, toolCalls],
);
const {
handleShellCommand,
activeShellPtyId,
@@ -409,11 +414,7 @@ export const useGeminiStream = (
terminalWidth,
terminalHeight,
activeBackgroundExecutionId,
);
const streamingState = useMemo(
() => calculateStreamingState(isResponding, toolCalls),
[isResponding, toolCalls],
streamingState === StreamingState.WaitingForConfirmation,
);
// Reset tracking when a new batch of tools starts
@@ -599,6 +600,7 @@ export const useGeminiStream = (
backgroundTasks,
settings.merged.ui?.compactToolOutput,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
const remainingTools = toolCalls.filter(
(tc) => !pushedToolCallIds.has(tc.request.callId),
@@ -983,16 +985,28 @@ export const useGeminiStream = (
if (postSubmitPrompt) {
localQueryToSendToGemini = postSubmitPrompt;
addItem(
{ type: MessageType.USER, text: trimmedQuery },
userMessageTimestamp,
);
return {
queryToSend: localQueryToSendToGemini,
shouldProceed: true,
};
}
addItem(
{ type: MessageType.USER, text: trimmedQuery },
userMessageTimestamp,
);
return { queryToSend: null, shouldProceed: false };
}
case 'submit_prompt': {
localQueryToSendToGemini = slashCommandResult.content;
addItem(
{ type: MessageType.USER, text: trimmedQuery },
userMessageTimestamp,
);
return {
queryToSend: localQueryToSendToGemini,
+4 -1
View File
@@ -93,13 +93,16 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
// References are converted to text for the model
result.push({ text: part.text });
break;
default:
default: {
const _exhaustiveCheck: never = part;
void _exhaustiveCheck;
debugLogger.warn(
`Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`,
);
// Serialize unknown ContentPart variants instead of dropping them
result.push({ text: JSON.stringify(part) });
break;
}
}
}
return result;
-1
View File
@@ -2019,7 +2019,6 @@ describe('Server Config (config.ts)', () => {
expect(configInternal.lastEmittedQuotaRemaining).toBeUndefined();
expect(configInternal.lastEmittedQuotaLimit).toBeUndefined();
expect(configInternal.lastQuotaFetchTime).toBe(0);
expect(configInternal.hasAccessToPreviewModel).toBeNull();
// Event emission
expect(emitQuotaSpy).toHaveBeenCalledWith(undefined, undefined, undefined);
+8 -1
View File
@@ -237,6 +237,7 @@ export interface GemmaModelRouterSettings {
export interface ADKSettings {
agentSessionNoninteractiveEnabled?: boolean;
agentSessionInteractiveEnabled?: boolean;
agentSessionSubagentEnabled?: boolean;
}
export interface ExtensionSetting {
@@ -913,6 +914,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly gemmaModelRouter: GemmaModelRouterSettings;
private readonly agentSessionNoninteractiveEnabled: boolean;
private readonly agentSessionInteractiveEnabled: boolean;
private readonly agentSessionSubagentEnabled: boolean;
private readonly retryFetchErrors: boolean;
private readonly maxAttempts: number;
@@ -1359,6 +1361,8 @@ export class Config implements McpContext, AgentLoopContext {
params.adk?.agentSessionNoninteractiveEnabled ?? false;
this.agentSessionInteractiveEnabled =
params.adk?.agentSessionInteractiveEnabled ?? false;
this.agentSessionSubagentEnabled =
params.adk?.agentSessionSubagentEnabled ?? false;
this.retryFetchErrors = params.retryFetchErrors ?? true;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
@@ -1833,7 +1837,6 @@ export class Config implements McpContext, AgentLoopContext {
this.modelQuotas.clear();
this.lastRetrievedQuota = undefined;
this.lastQuotaFetchTime = 0;
this.hasAccessToPreviewModel = null;
// Force an event emission to clear the UI display
coreEvents.emitQuotaChanged(undefined, undefined, undefined);
@@ -2574,6 +2577,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.contextManagement.enabled;
}
isAgentSessionSubagentEnabled(): boolean {
return this.agentSessionSubagentEnabled;
}
getMemoryBoundaryMarkers(): readonly string[] {
return this.memoryBoundaryMarkers;
}
@@ -71,6 +71,24 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-flash-preview',
},
},
'gemini-3.1-pro-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview',
},
},
'gemini-3.1-pro-preview-customtools': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-pro-preview-customtools',
},
},
'gemini-3.1-flash-lite-preview': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-flash-lite-preview',
},
},
'gemini-2.5-pro': {
extends: 'chat-base-2.5',
modelConfig: {
+32
View File
@@ -672,3 +672,35 @@ describe('isActiveModel', () => {
).toBe(false);
});
});
describe('Gemini 3.1 Config Resolution', () => {
it('PREVIEW_GEMINI_3_1_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
});
});
+1 -1
View File
@@ -164,7 +164,7 @@ export function resolveModel(
switch (normalizedModel) {
case GEMINI_MODEL_ALIAS_AUTO:
case GEMINI_MODEL_ALIAS_PRO: {
if (currentReleaseChannel === 'stable') {
if (!hasAccessToPreview) {
resolved = DEFAULT_GEMINI_MODEL;
break;
}
@@ -61,6 +61,42 @@
"topK": 64
}
},
"gemini-3.1-pro-preview": {
"model": "gemini-3.1-pro-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-pro-preview-customtools": {
"model": "gemini-3.1-pro-preview-customtools",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-flash-lite-preview": {
"model": "gemini-3.1-flash-lite-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
@@ -61,6 +61,42 @@
"topK": 64
}
},
"gemini-3.1-pro-preview": {
"model": "gemini-3.1-pro-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-pro-preview-customtools": {
"model": "gemini-3.1-pro-preview-customtools",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-3.1-flash-lite-preview": {
"model": "gemini-3.1-flash-lite-preview",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-pro": {
"model": "gemini-2.5-pro",
"generateContentConfig": {
+46 -10
View File
@@ -5,7 +5,7 @@
*/
import { updateGlobalFetchTimeouts } from './fetch.js';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress, LookupAllOptions } from 'node:dns';
import ipaddr from 'ipaddr.js';
@@ -34,18 +34,14 @@ const {
fetchWithTimeout,
setGlobalProxy,
} = await import('./fetch.js');
// Mock global fetch
const originalFetch = global.fetch;
global.fetch = vi.fn();
interface ErrorWithCode extends Error {
code?: string;
}
describe('fetch utils', () => {
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(global, 'fetch').mockImplementation(vi.fn() as any);
// Default DNS lookup to return a public IP, or the IP itself if valid
vi.mocked(
dnsPromises.lookup as (
@@ -60,8 +56,8 @@ describe('fetch utils', () => {
});
});
afterAll(() => {
global.fetch = originalFetch;
afterEach(() => {
vi.restoreAllMocks();
});
describe('isAddressPrivate', () => {
@@ -177,7 +173,7 @@ describe('fetch utils', () => {
});
describe('fetchWithTimeout', () => {
it('should handle timeouts', async () => {
it('should throw FetchError with ETIMEDOUT on an internal timeout', async () => {
vi.mocked(global.fetch).mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
@@ -198,6 +194,46 @@ describe('fetch utils', () => {
'Request timed out after 50ms',
);
});
it('should throw an AbortError (not ETIMEDOUT) when the caller signal is aborted', async () => {
vi.mocked(global.fetch).mockImplementation(
(_input, init) =>
new Promise((_resolve, reject) => {
const rejectWithAbortError = () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
// @ts-expect-error - for mocking purposes
error.code = 'ABORT_ERR';
reject(error);
};
// Handle the case where the signal is already aborted before
// fetch is called (e.g. controller.abort() called synchronously).
if (init?.signal?.aborted) {
rejectWithAbortError();
return;
}
if (init?.signal) {
init.signal.addEventListener('abort', rejectWithAbortError, {
once: true,
});
}
}),
);
const controller = new AbortController();
// Abort the external signal before the request even starts
controller.abort();
const rejection = fetchWithTimeout('http://example.com', 10_000, {
signal: controller.signal,
});
await expect(rejection).rejects.toMatchObject({ name: 'AbortError' });
// Must NOT be classified as a timeout
await expect(rejection).rejects.not.toThrow('timed out');
});
});
describe('setGlobalProxy', () => {
+10 -2
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { getErrorMessage, isNodeError } from './errors.js';
import { getErrorMessage, isAbortError } from './errors.js';
import { URL } from 'node:url';
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
import ipaddr from 'ipaddr.js';
@@ -202,7 +202,15 @@ export async function fetchWithTimeout(
});
return response;
} catch (error) {
if (isNodeError(error) && error.code === 'ABORT_ERR') {
if (isAbortError(error)) {
// If the caller's own signal was already aborted, this is a user-initiated
// cancellation (e.g. Ctrl+C), not an internal timeout. Re-throw as a plain
// AbortError so the retry layer does NOT treat it as a retryable ETIMEDOUT.
if (options?.signal?.aborted) {
// Rethrow the original abort reason or the caught error to preserve
// the stack trace and any custom abort reason (e.g. from Ctrl+C).
throw options.signal.reason ?? error;
}
throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT');
}
throw new FetchError(getErrorMessage(error), undefined, { cause: error });
File diff suppressed because one or more lines are too long