mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-27 02:01:00 -07:00
Compare commits
9 Commits
wrong-model
..
adk
| Author | SHA1 | Date | |
|---|---|---|---|
| d78db4f514 | |||
| 3e1be515e5 | |||
| 469f5b7a68 | |||
| b2946b1052 | |||
| 84423e6ea1 | |||
| 5611ff40e7 | |||
| 77e65c0db5 | |||
| b36788eb2a | |||
| d32c9b77df |
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -324,38 +324,6 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('model fallback', () => {
|
||||
it('should fallback to default model when an obsolete model is provided', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
model: 'gemini-pro-latest',
|
||||
});
|
||||
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should fallback to default model when an invalid gemini model is provided', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
model: 'gemini-invalid-model',
|
||||
});
|
||||
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should fallback to default model when setModel is called with an invalid gemini model', () => {
|
||||
const config = new Config(baseParams);
|
||||
config.setModel('gemini-invalid-model');
|
||||
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('should allow custom models (non-gemini prefixed)', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
model: 'custom-model',
|
||||
});
|
||||
expect(config.getModel()).toBe('custom-model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setShellExecutionConfig', () => {
|
||||
it('should preserve existing shell execution fields that are not being updated', () => {
|
||||
const config = new Config({
|
||||
@@ -2051,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);
|
||||
@@ -2645,7 +2612,7 @@ describe('Config getHooks', () => {
|
||||
targetDir: '/path/to/target',
|
||||
debugMode: false,
|
||||
sessionId: 'test-session-id',
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-pro',
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
|
||||
@@ -2894,7 +2861,7 @@ describe('Config getExperiments', () => {
|
||||
targetDir: '/path/to/target',
|
||||
debugMode: false,
|
||||
sessionId: 'test-session-id',
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-pro',
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
|
||||
@@ -2939,7 +2906,7 @@ describe('Config setExperiments logging', () => {
|
||||
targetDir: '/path/to/target',
|
||||
debugMode: false,
|
||||
sessionId: 'test-session-id',
|
||||
model: 'gemini-2.5-flash',
|
||||
model: 'gemini-pro',
|
||||
usageStatisticsEnabled: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -80,12 +80,10 @@ import { tokenLimit } from '../core/tokenLimits.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
isAutoModel,
|
||||
isPreviewModel,
|
||||
isGemini2Model,
|
||||
isValidModel,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
resolveModel,
|
||||
} from './models.js';
|
||||
@@ -239,6 +237,7 @@ export interface GemmaModelRouterSettings {
|
||||
export interface ADKSettings {
|
||||
agentSessionNoninteractiveEnabled?: boolean;
|
||||
agentSessionInteractiveEnabled?: boolean;
|
||||
agentSessionSubagentEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ExtensionSetting {
|
||||
@@ -915,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;
|
||||
@@ -1116,11 +1116,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.cwd = params.cwd ?? process.cwd();
|
||||
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
|
||||
this.bugCommand = params.bugCommand;
|
||||
this.model = isValidModel(params.model)
|
||||
? params.model
|
||||
: DEFAULT_GEMINI_MODEL;
|
||||
this.model = params.model;
|
||||
this.disableLoopDetection = params.disableLoopDetection ?? false;
|
||||
this._activeModel = this.model;
|
||||
this._activeModel = params.model;
|
||||
this.enableAgents = params.enableAgents ?? true;
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
@@ -1363,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,
|
||||
@@ -1837,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);
|
||||
@@ -1906,20 +1905,17 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
setModel(newModel: string, isTemporary: boolean = true): void {
|
||||
const validatedModel = isValidModel(newModel)
|
||||
? newModel
|
||||
: DEFAULT_GEMINI_MODEL;
|
||||
if (this.model !== validatedModel || this._activeModel !== validatedModel) {
|
||||
this.model = validatedModel;
|
||||
if (this.model !== newModel || this._activeModel !== newModel) {
|
||||
this.model = newModel;
|
||||
// When the user explicitly sets a model, that becomes the active model.
|
||||
this._activeModel = validatedModel;
|
||||
coreEvents.emitModelChanged(validatedModel);
|
||||
this._activeModel = newModel;
|
||||
coreEvents.emitModelChanged(newModel);
|
||||
this.lastEmittedQuotaRemaining = undefined;
|
||||
this.lastEmittedQuotaLimit = undefined;
|
||||
this.emitQuotaChangedEvent();
|
||||
}
|
||||
if (this.onModelChange && !isTemporary) {
|
||||
this.onModelChange(validatedModel);
|
||||
this.onModelChange(newModel);
|
||||
}
|
||||
this.modelAvailabilityService.reset();
|
||||
}
|
||||
@@ -2581,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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { normalizeModelId } from '../utils/modelUtils.js';
|
||||
|
||||
export interface ModelResolutionContext {
|
||||
useGemini3_1?: boolean;
|
||||
useGemini3_1FlashLite?: boolean;
|
||||
@@ -112,20 +110,6 @@ export function getAutoModelDescription(
|
||||
return `Let Gemini CLI decide the best model for the task: ${proModel}, ${flashModel}`;
|
||||
}
|
||||
|
||||
export function isValidModel(model: string): boolean {
|
||||
const normalized = normalizeModelId(model);
|
||||
return (
|
||||
VALID_GEMINI_MODELS.has(normalized) ||
|
||||
normalized === GEMINI_MODEL_ALIAS_AUTO ||
|
||||
normalized === GEMINI_MODEL_ALIAS_PRO ||
|
||||
normalized === GEMINI_MODEL_ALIAS_FLASH ||
|
||||
normalized === GEMINI_MODEL_ALIAS_FLASH_LITE ||
|
||||
normalized === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
normalized === DEFAULT_GEMINI_MODEL_AUTO ||
|
||||
!normalized.startsWith('gemini-')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the requested model alias (e.g., 'auto', 'pro', 'flash', 'flash-lite')
|
||||
* to a concrete model name.
|
||||
@@ -180,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;
|
||||
}
|
||||
@@ -212,12 +196,7 @@ export function resolveModel(
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (!isValidModel(normalizedModel)) {
|
||||
// Fallback to stable default for unknown/obsolete models.
|
||||
resolved = DEFAULT_GEMINI_MODEL;
|
||||
} else {
|
||||
resolved = normalizedModel;
|
||||
}
|
||||
resolved = normalizedModel;
|
||||
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": {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user