mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
Merge branch 'main' into memory_usage3
This commit is contained in:
@@ -136,6 +136,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
|||||||
getRetryFetchErrors: vi.fn().mockReturnValue(true),
|
getRetryFetchErrors: vi.fn().mockReturnValue(true),
|
||||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||||
|
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
|
||||||
getShellExecutionConfig: vi.fn().mockReturnValue({
|
getShellExecutionConfig: vi.fn().mockReturnValue({
|
||||||
sandboxManager: new NoopSandboxManager(),
|
sandboxManager: new NoopSandboxManager(),
|
||||||
sanitizationConfig: {
|
sanitizationConfig: {
|
||||||
|
|||||||
@@ -6,11 +6,7 @@
|
|||||||
|
|
||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
import { createMockSettings } from '../../test-utils/settings.js';
|
import { createMockSettings } from '../../test-utils/settings.js';
|
||||||
import {
|
import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||||
makeFakeConfig,
|
|
||||||
CoreToolCallStatus,
|
|
||||||
UPDATE_TOPIC_TOOL_NAME,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
import { waitFor } from '../../test-utils/async.js';
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
import { MainContent } from './MainContent.js';
|
import { MainContent } from './MainContent.js';
|
||||||
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
|
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
|
||||||
@@ -732,158 +728,6 @@ describe('MainContent', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Narration Suppression', () => {
|
|
||||||
const settingsWithNarration = createMockSettings({
|
|
||||||
merged: {
|
|
||||||
ui: { inlineThinkingMode: 'expanded' },
|
|
||||||
experimental: { topicUpdateNarration: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses thinking ALWAYS when narration is enabled', async () => {
|
|
||||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
|
||||||
const uiState = {
|
|
||||||
...defaultMockUiState,
|
|
||||||
history: [
|
|
||||||
{ id: 1, type: 'user' as const, text: 'Hello' },
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
type: 'thinking' as const,
|
|
||||||
thought: {
|
|
||||||
subject: 'Thinking...',
|
|
||||||
description: 'Thinking about hello',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ id: 3, type: 'gemini' as const, text: 'I am helping.' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<MainContent />,
|
|
||||||
{
|
|
||||||
uiState: uiState as Partial<UIState>,
|
|
||||||
settings: settingsWithNarration,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const output = lastFrame();
|
|
||||||
expect(output).not.toContain('Thinking...');
|
|
||||||
expect(output).toContain('I am helping.');
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses text in intermediate turns (contains non-topic tools)', async () => {
|
|
||||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
|
||||||
const uiState = {
|
|
||||||
...defaultMockUiState,
|
|
||||||
history: [
|
|
||||||
{ id: 100, type: 'user' as const, text: 'Search' },
|
|
||||||
{
|
|
||||||
id: 101,
|
|
||||||
type: 'gemini' as const,
|
|
||||||
text: 'I will now search the files.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 102,
|
|
||||||
type: 'tool_group' as const,
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
callId: '1',
|
|
||||||
name: 'ls',
|
|
||||||
args: { path: '.' },
|
|
||||||
status: CoreToolCallStatus.Success,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<MainContent />,
|
|
||||||
{
|
|
||||||
uiState: uiState as Partial<UIState>,
|
|
||||||
settings: settingsWithNarration,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const output = lastFrame();
|
|
||||||
expect(output).not.toContain('I will now search the files.');
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses text that precedes a topic tool in the same turn', async () => {
|
|
||||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
|
||||||
const uiState = {
|
|
||||||
...defaultMockUiState,
|
|
||||||
history: [
|
|
||||||
{ id: 200, type: 'user' as const, text: 'Hello' },
|
|
||||||
{ id: 201, type: 'gemini' as const, text: 'I will now help you.' },
|
|
||||||
{
|
|
||||||
id: 202,
|
|
||||||
type: 'tool_group' as const,
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
callId: '1',
|
|
||||||
name: UPDATE_TOPIC_TOOL_NAME,
|
|
||||||
args: { title: 'Helping', summary: 'Helping the user' },
|
|
||||||
status: CoreToolCallStatus.Success,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<MainContent />,
|
|
||||||
{
|
|
||||||
uiState: uiState as Partial<UIState>,
|
|
||||||
settings: settingsWithNarration,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const output = lastFrame();
|
|
||||||
expect(output).not.toContain('I will now help you.');
|
|
||||||
expect(output).toContain('Helping');
|
|
||||||
expect(output).toContain('Helping the user');
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows text in the final turn if it comes AFTER the topic tool', async () => {
|
|
||||||
mockUseSettings.mockReturnValue(settingsWithNarration);
|
|
||||||
const uiState = {
|
|
||||||
...defaultMockUiState,
|
|
||||||
history: [
|
|
||||||
{ id: 300, type: 'user' as const, text: 'Hello' },
|
|
||||||
{
|
|
||||||
id: 301,
|
|
||||||
type: 'tool_group' as const,
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
callId: '1',
|
|
||||||
name: UPDATE_TOPIC_TOOL_NAME,
|
|
||||||
args: { title: 'Final Answer', summary: 'I have finished' },
|
|
||||||
status: CoreToolCallStatus.Success,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: 302, type: 'gemini' as const, text: 'Here is your answer.' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<MainContent />,
|
|
||||||
{
|
|
||||||
uiState: uiState as Partial<UIState>,
|
|
||||||
settings: settingsWithNarration,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const output = lastFrame();
|
|
||||||
expect(output).toContain('Here is your answer.');
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders multiple thinking messages sequentially correctly', async () => {
|
it('renders multiple thinking messages sequentially correctly', async () => {
|
||||||
mockUseSettings.mockReturnValue({
|
mockUseSettings.mockReturnValue({
|
||||||
merged: {
|
merged: {
|
||||||
|
|||||||
@@ -91,47 +91,20 @@ export const MainContent = () => {
|
|||||||
const flags = new Array<boolean>(combinedHistory.length).fill(false);
|
const flags = new Array<boolean>(combinedHistory.length).fill(false);
|
||||||
|
|
||||||
if (topicUpdateNarrationEnabled) {
|
if (topicUpdateNarrationEnabled) {
|
||||||
let turnIsIntermediate = false;
|
let toolGroupInTurn = false;
|
||||||
let hasTopicToolInTurn = false;
|
|
||||||
|
|
||||||
for (let i = combinedHistory.length - 1; i >= 0; i--) {
|
for (let i = combinedHistory.length - 1; i >= 0; i--) {
|
||||||
const item = combinedHistory[i];
|
const item = combinedHistory[i];
|
||||||
if (item.type === 'user' || item.type === 'user_shell') {
|
if (item.type === 'user' || item.type === 'user_shell') {
|
||||||
turnIsIntermediate = false;
|
toolGroupInTurn = false;
|
||||||
hasTopicToolInTurn = false;
|
|
||||||
} else if (item.type === 'tool_group') {
|
} else if (item.type === 'tool_group') {
|
||||||
const hasTopic = item.tools.some((t) => isTopicTool(t.name));
|
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
|
||||||
const hasNonTopic = item.tools.some((t) => !isTopicTool(t.name));
|
|
||||||
if (hasTopic) {
|
|
||||||
hasTopicToolInTurn = true;
|
|
||||||
}
|
|
||||||
if (hasNonTopic) {
|
|
||||||
turnIsIntermediate = true;
|
|
||||||
}
|
|
||||||
} else if (
|
} else if (
|
||||||
item.type === 'thinking' ||
|
(item.type === 'thinking' ||
|
||||||
item.type === 'gemini' ||
|
item.type === 'gemini' ||
|
||||||
item.type === 'gemini_content'
|
item.type === 'gemini_content') &&
|
||||||
|
toolGroupInTurn
|
||||||
) {
|
) {
|
||||||
// Rule 1: Always suppress thinking when narration is enabled to avoid
|
|
||||||
// "flashing" as the model starts its response, and because the Topic
|
|
||||||
// UI provides the necessary high-level intent.
|
|
||||||
if (item.type === 'thinking') {
|
|
||||||
flags[i] = true;
|
flags[i] = true;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 2: Suppress text in intermediate turns (turns containing non-topic
|
|
||||||
// tools) to hide mechanical narration.
|
|
||||||
if (turnIsIntermediate) {
|
|
||||||
flags[i] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rule 3: Suppress text that precedes a topic tool in the same turn,
|
|
||||||
// as the topic tool "replaces" it.
|
|
||||||
if (hasTopicToolInTurn) {
|
|
||||||
flags[i] = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const ExperimentFlags = {
|
|||||||
GEMINI_3_1_PRO_LAUNCHED: 45760185,
|
GEMINI_3_1_PRO_LAUNCHED: 45760185,
|
||||||
PRO_MODEL_NO_ACCESS: 45768879,
|
PRO_MODEL_NO_ACCESS: 45768879,
|
||||||
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
|
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
|
||||||
|
DEFAULT_REQUEST_TIMEOUT: 45773134,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ExperimentFlagName =
|
export type ExperimentFlagName =
|
||||||
|
|||||||
@@ -644,6 +644,58 @@ describe('Server Config (config.ts)', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getRequestTimeoutMs', () => {
|
||||||
|
it('should return undefined if the flag is not set', () => {
|
||||||
|
const config = new Config(baseParams);
|
||||||
|
expect(config.getRequestTimeoutMs()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return timeout in milliseconds if flag is set', () => {
|
||||||
|
const config = new Config({
|
||||||
|
...baseParams,
|
||||||
|
experiments: {
|
||||||
|
flags: {
|
||||||
|
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
|
||||||
|
intValue: '30',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
experimentIds: [],
|
||||||
|
},
|
||||||
|
} as unknown as ConfigParameters);
|
||||||
|
expect(config.getRequestTimeoutMs()).toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined if intValue is not a valid integer', () => {
|
||||||
|
const config = new Config({
|
||||||
|
...baseParams,
|
||||||
|
experiments: {
|
||||||
|
flags: {
|
||||||
|
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
|
||||||
|
intValue: 'abc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
experimentIds: [],
|
||||||
|
},
|
||||||
|
} as unknown as ConfigParameters);
|
||||||
|
expect(config.getRequestTimeoutMs()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined if intValue is negative', () => {
|
||||||
|
const config = new Config({
|
||||||
|
...baseParams,
|
||||||
|
experiments: {
|
||||||
|
flags: {
|
||||||
|
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
|
||||||
|
intValue: '-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
experimentIds: [],
|
||||||
|
},
|
||||||
|
} as unknown as ConfigParameters);
|
||||||
|
expect(config.getRequestTimeoutMs()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('refreshAuth', () => {
|
describe('refreshAuth', () => {
|
||||||
@@ -2078,8 +2130,18 @@ describe('BaseLlmClient Lifecycle', () => {
|
|||||||
usageStatisticsEnabled: false,
|
usageStatisticsEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
it('should throw an error if getBaseLlmClient is called before experiments have been fetched', () => {
|
||||||
|
const config = new Config(baseParams);
|
||||||
|
// By default on a new Config instance, experiments are undefined
|
||||||
|
expect(() => config.getBaseLlmClient()).toThrow(
|
||||||
|
'BaseLlmClient not initialized. Ensure experiments have been fetched and configuration is ready.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw an error if getBaseLlmClient is called before refreshAuth', () => {
|
it('should throw an error if getBaseLlmClient is called before refreshAuth', () => {
|
||||||
const config = new Config(baseParams);
|
const config = new Config(baseParams);
|
||||||
|
// Explicitly set experiments to avoid triggering the new missing-experiments error
|
||||||
|
config.setExperiments({ flags: {}, experimentIds: [] });
|
||||||
expect(() => config.getBaseLlmClient()).toThrow(
|
expect(() => config.getBaseLlmClient()).toThrow(
|
||||||
'BaseLlmClient not initialized. Ensure authentication has occurred and ContentGenerator is ready.',
|
'BaseLlmClient not initialized. Ensure authentication has occurred and ContentGenerator is ready.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ import {
|
|||||||
} from '../code_assist/experiments/experiments.js';
|
} from '../code_assist/experiments/experiments.js';
|
||||||
import { AgentRegistry } from '../agents/registry.js';
|
import { AgentRegistry } from '../agents/registry.js';
|
||||||
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
||||||
import { setGlobalProxy } from '../utils/fetch.js';
|
import { setGlobalProxy, updateGlobalFetchTimeouts } from '../utils/fetch.js';
|
||||||
import { SubagentTool } from '../agents/subagent-tool.js';
|
import { SubagentTool } from '../agents/subagent-tool.js';
|
||||||
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
|
||||||
import { debugLogger } from '../utils/debugLogger.js';
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
@@ -1548,9 +1548,6 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
// Only assign to instance properties after successful initialization
|
// Only assign to instance properties after successful initialization
|
||||||
this.contentGeneratorConfig = newContentGeneratorConfig;
|
this.contentGeneratorConfig = newContentGeneratorConfig;
|
||||||
|
|
||||||
// Initialize BaseLlmClient now that the ContentGenerator is available
|
|
||||||
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
|
|
||||||
|
|
||||||
const codeAssistServer = getCodeAssistServer(this);
|
const codeAssistServer = getCodeAssistServer(this);
|
||||||
const quotaPromise = codeAssistServer?.projectId
|
const quotaPromise = codeAssistServer?.projectId
|
||||||
? this.refreshUserQuota()
|
? this.refreshUserQuota()
|
||||||
@@ -1566,6 +1563,17 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch experiments and update timeouts before continuing initialization
|
||||||
|
const experiments = await this.experimentsPromise;
|
||||||
|
|
||||||
|
const requestTimeoutMs = this.getRequestTimeoutMs();
|
||||||
|
if (requestTimeoutMs !== undefined) {
|
||||||
|
updateGlobalFetchTimeouts(requestTimeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize BaseLlmClient now that the ContentGenerator and experiments are available
|
||||||
|
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
|
||||||
|
|
||||||
await quotaPromise;
|
await quotaPromise;
|
||||||
|
|
||||||
const authType = this.contentGeneratorConfig.authType;
|
const authType = this.contentGeneratorConfig.authType;
|
||||||
@@ -1585,9 +1593,6 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch admin controls
|
|
||||||
const experiments = await this.experimentsPromise;
|
|
||||||
|
|
||||||
const adminControlsEnabled =
|
const adminControlsEnabled =
|
||||||
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
|
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
|
||||||
false;
|
false;
|
||||||
@@ -1633,6 +1638,11 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
getBaseLlmClient(): BaseLlmClient {
|
getBaseLlmClient(): BaseLlmClient {
|
||||||
if (!this.baseLlmClient) {
|
if (!this.baseLlmClient) {
|
||||||
// Handle cases where initialization might be deferred or authentication failed
|
// Handle cases where initialization might be deferred or authentication failed
|
||||||
|
if (!this.experiments) {
|
||||||
|
throw new Error(
|
||||||
|
'BaseLlmClient not initialized. Ensure experiments have been fetched and configuration is ready.',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (this.contentGenerator) {
|
if (this.contentGenerator) {
|
||||||
this.baseLlmClient = new BaseLlmClient(
|
this.baseLlmClient = new BaseLlmClient(
|
||||||
this.getContentGenerator(),
|
this.getContentGenerator(),
|
||||||
@@ -3153,6 +3163,21 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configured default request timeout in milliseconds.
|
||||||
|
*/
|
||||||
|
getRequestTimeoutMs(): number | undefined {
|
||||||
|
const flag =
|
||||||
|
this.experiments?.flags?.[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT];
|
||||||
|
if (flag?.intValue !== undefined) {
|
||||||
|
const seconds = parseInt(flag.intValue, 10);
|
||||||
|
if (Number.isInteger(seconds) && seconds >= 0) {
|
||||||
|
return seconds * 1000; // Convert seconds to milliseconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether Gemini 3.1 Flash Lite has been launched.
|
* Returns whether Gemini 3.1 Flash Lite has been launched.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ describe('BaseLlmClient', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
|
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
|
||||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||||
getContentGeneratorConfig: vi
|
getContentGeneratorConfig: vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
authType: AuthType.USE_GEMINI,
|
authType: AuthType.USE_GEMINI,
|
||||||
};
|
};
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
|
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
|
||||||
getContentGeneratorConfig: vi
|
getContentGeneratorConfig: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValue(contentGeneratorConfig),
|
.mockReturnValue(contentGeneratorConfig),
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ describe('GeminiChat', () => {
|
|||||||
let currentActiveModel = 'gemini-pro';
|
let currentActiveModel = 'gemini-pro';
|
||||||
|
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
|
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
|
||||||
get config() {
|
get config() {
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ describe('GeminiChat Network Retries', () => {
|
|||||||
const testMessageBus = { publish: vi.fn(), subscribe: vi.fn() };
|
const testMessageBus = { publish: vi.fn(), subscribe: vi.fn() };
|
||||||
|
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
|
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
|
||||||
get config() {
|
get config() {
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,21 +4,37 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { updateGlobalFetchTimeouts } from './fetch.js';
|
||||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||||
import {
|
|
||||||
isPrivateIp,
|
|
||||||
isPrivateIpAsync,
|
|
||||||
isAddressPrivate,
|
|
||||||
fetchWithTimeout,
|
|
||||||
} from './fetch.js';
|
|
||||||
import * as dnsPromises from 'node:dns/promises';
|
import * as dnsPromises from 'node:dns/promises';
|
||||||
import type { LookupAddress, LookupAllOptions } from 'node:dns';
|
import type { LookupAddress, LookupAllOptions } from 'node:dns';
|
||||||
import ipaddr from 'ipaddr.js';
|
import ipaddr from 'ipaddr.js';
|
||||||
|
|
||||||
|
const { setGlobalDispatcher, Agent, ProxyAgent } = vi.hoisted(() => ({
|
||||||
|
setGlobalDispatcher: vi.fn(),
|
||||||
|
Agent: vi.fn(),
|
||||||
|
ProxyAgent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('undici', () => ({
|
||||||
|
setGlobalDispatcher,
|
||||||
|
Agent,
|
||||||
|
ProxyAgent,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('node:dns/promises', () => ({
|
vi.mock('node:dns/promises', () => ({
|
||||||
lookup: vi.fn(),
|
lookup: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Import after mocks are established
|
||||||
|
const {
|
||||||
|
isPrivateIp,
|
||||||
|
isPrivateIpAsync,
|
||||||
|
isAddressPrivate,
|
||||||
|
fetchWithTimeout,
|
||||||
|
setGlobalProxy,
|
||||||
|
} = await import('./fetch.js');
|
||||||
|
|
||||||
// Mock global fetch
|
// Mock global fetch
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
global.fetch = vi.fn();
|
global.fetch = vi.fn();
|
||||||
@@ -183,4 +199,19 @@ describe('fetch utils', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setGlobalProxy', () => {
|
||||||
|
it('should configure ProxyAgent with experiment flag timeout', () => {
|
||||||
|
const proxyUrl = 'http://proxy.example.com';
|
||||||
|
updateGlobalFetchTimeouts(45773134);
|
||||||
|
setGlobalProxy(proxyUrl);
|
||||||
|
|
||||||
|
expect(ProxyAgent).toHaveBeenCalledWith({
|
||||||
|
uri: proxyUrl,
|
||||||
|
headersTimeout: 45773134,
|
||||||
|
bodyTimeout: 45773134,
|
||||||
|
});
|
||||||
|
expect(setGlobalDispatcher).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
|||||||
import ipaddr from 'ipaddr.js';
|
import ipaddr from 'ipaddr.js';
|
||||||
import { lookup } from 'node:dns/promises';
|
import { lookup } from 'node:dns/promises';
|
||||||
|
|
||||||
const DEFAULT_HEADERS_TIMEOUT = 300000; // 5 minutes
|
|
||||||
const DEFAULT_BODY_TIMEOUT = 300000; // 5 minutes
|
|
||||||
|
|
||||||
export class FetchError extends Error {
|
export class FetchError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -31,14 +28,36 @@ export class PrivateIpError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let defaultTimeout = 300000; // 5 minutes
|
||||||
|
let currentProxy: string | undefined = undefined;
|
||||||
|
|
||||||
// Configure default global dispatcher with higher timeouts
|
// Configure default global dispatcher with higher timeouts
|
||||||
setGlobalDispatcher(
|
setGlobalDispatcher(
|
||||||
new Agent({
|
new Agent({
|
||||||
headersTimeout: DEFAULT_HEADERS_TIMEOUT,
|
headersTimeout: defaultTimeout,
|
||||||
bodyTimeout: DEFAULT_BODY_TIMEOUT,
|
bodyTimeout: defaultTimeout,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function updateGlobalFetchTimeouts(timeoutMs: number) {
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Invalid timeout value: ${timeoutMs}. Must be a positive finite number.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
defaultTimeout = timeoutMs;
|
||||||
|
if (currentProxy) {
|
||||||
|
setGlobalProxy(currentProxy);
|
||||||
|
} else {
|
||||||
|
setGlobalDispatcher(
|
||||||
|
new Agent({
|
||||||
|
headersTimeout: defaultTimeout,
|
||||||
|
bodyTimeout: defaultTimeout,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitizes a hostname by stripping IPv6 brackets if present.
|
* Sanitizes a hostname by stripping IPv6 brackets if present.
|
||||||
*/
|
*/
|
||||||
@@ -191,11 +210,12 @@ export async function fetchWithTimeout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setGlobalProxy(proxy: string) {
|
export function setGlobalProxy(proxy: string) {
|
||||||
|
currentProxy = proxy;
|
||||||
setGlobalDispatcher(
|
setGlobalDispatcher(
|
||||||
new ProxyAgent({
|
new ProxyAgent({
|
||||||
uri: proxy,
|
uri: proxy,
|
||||||
headersTimeout: DEFAULT_HEADERS_TIMEOUT,
|
headersTimeout: defaultTimeout,
|
||||||
bodyTimeout: DEFAULT_BODY_TIMEOUT,
|
bodyTimeout: defaultTimeout,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user