Compare commits

...

2 Commits

Author SHA1 Message Date
Michael Bleigh c22187d731 feat(cli): add dynamic agent switcher and /agent subcommand
- Implemented /agent command with manage (opens dialog) and set subcommands.
- Created AgentDialog component for choosing between standard and enterprise agents.
- Integrated remember agent setting with persist toggle.
- Wired --agent cli argument for booting directly into specific agent.
- Handled clean session remounting on agent swap via React key-swapping of AppContainer, fully complying with Rules of Hooks.
- Added agent-name persistently to the status bar footer items.
- Resolved dynamic routing inside non-interactive headless loops to fully support Gemini Enterprise prompts.

TAG=agy
CONV=81e82460-f8cd-4c7b-a037-2cbedda4d3c0
2026-05-18 19:01:39 +00:00
Michael Bleigh fc8928c089 feat: add support for Gemini Enterprise (Discovery Engine) assistant
- Implemented EnterpriseAgentProtocol and EnterpriseAgentSession in core
- Authenticates seamlessly via Application Default Credentials (ADC)
- Added robust brace-counting JSON stream parser with partial chunk caching
- Extracted and rendered immersive docArtifacts (markdown tables) E2E
- Integrated with CLI config schema and enabled default 'all tools' execution
- Added comprehensive unit tests verifying all stream events (thoughts, tools, tables)

TAG=agy
CONV=81e82460-f8cd-4c7b-a037-2cbedda4d3c0
2026-05-18 00:59:55 +00:00
33 changed files with 1543 additions and 35 deletions
+23
View File
@@ -51,6 +51,7 @@ import {
type Settings,
type MergedSettings,
saveModelChange,
saveAgentChange,
loadSettings,
isWorktreeEnabled,
type LoadedSettings,
@@ -77,6 +78,7 @@ import { runExitCleanup } from '../utils/cleanup.js';
export interface CliArgs {
query: string | undefined;
model: string | undefined;
agent?: string;
sandbox: boolean | string | undefined;
debug: boolean | undefined;
prompt: string | undefined;
@@ -289,6 +291,12 @@ export async function parseArguments(
nargs: 1,
description: `Model`,
})
.option('agent', {
alias: 'a',
type: 'string',
nargs: 1,
description: `Agent to use: gemini-cli or gemini-enterprise`,
})
.option('prompt', {
alias: 'p',
type: 'string',
@@ -840,6 +848,18 @@ export async function loadCliConfig(
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
? defaultModel
: specifiedModel || defaultModel;
const defaultAgent = 'gemini-cli';
const rawAgent =
argv.agent || process.env['GEMINI_CLI_AGENT'] || settings.agent?.name;
const specifiedAgent = Array.isArray(rawAgent)
? String(rawAgent.at(-1) ?? '').trim() || ''
: rawAgent === undefined
? undefined
: String(rawAgent ?? '').trim() || '';
const resolvedAgent = specifiedAgent || defaultAgent;
const sandboxConfig = await loadSandboxConfig(settings, argv);
if (sandboxConfig) {
const existingPaths = sandboxConfig.allowedPaths || [];
@@ -974,6 +994,7 @@ export async function loadCliConfig(
mcpEnabled,
extensionsEnabled,
agents: settings.agents,
enterprise: settings.enterprise,
adminSkillsEnabled,
allowedMcpServers: mcpEnabled
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
@@ -1013,6 +1034,7 @@ export async function loadCliConfig(
fileDiscoveryService: fileService,
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
agent: resolvedAgent,
maxSessionTurns: settings.model?.maxSessionTurns,
listExtensions: argv.listExtensions || false,
@@ -1092,6 +1114,7 @@ export async function loadCliConfig(
disabledHooks: settings.hooksConfig?.disabled || [],
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model),
onAgentChange: (agent: string) => saveAgentChange(loadSettings(cwd), agent),
onReload: async () => {
const refreshedSettings = loadSettings(cwd);
return {
@@ -23,6 +23,7 @@ describe('footerItems', () => {
'git-branch',
'sandbox',
'model-name',
'agent-name',
'quota',
]);
});
@@ -91,6 +92,7 @@ describe('footerItems', () => {
'git-branch',
'sandbox',
'context-used',
'agent-name',
'memory-usage',
]);
});
@@ -117,6 +119,7 @@ describe('footerItems', () => {
'git-branch',
'sandbox',
'context-used',
'agent-name',
'quota',
'memory-usage',
'session-id',
+7
View File
@@ -27,6 +27,11 @@ export const ALL_ITEMS = [
header: '/model',
description: 'Current model identifier',
},
{
id: 'agent-name',
header: '/agent',
description: 'Current active agent name',
},
{
id: 'context-used',
header: 'context',
@@ -76,6 +81,7 @@ export const DEFAULT_ORDER = [
'git-branch',
'sandbox',
'model-name',
'agent-name',
'context-used',
'quota',
'memory-usage',
@@ -94,6 +100,7 @@ export function deriveItemsFromLegacySettings(
'git-branch',
'sandbox',
'model-name',
'agent-name',
'quota',
];
const items = [...defaults];
+16
View File
@@ -1197,6 +1197,22 @@ export function saveModelChange(
}
}
export function saveAgentChange(
loadedSettings: LoadedSettings,
agent: string,
): void {
try {
loadedSettings.setValue(SettingScope.User, 'agent.name', agent);
} catch (error) {
const detailedErrorMessage = getFsErrorMessage(error);
coreEvents.emitFeedback(
'error',
`Failed to save preferred agent: ${detailedErrorMessage}`,
error,
);
}
}
function migrateExperimentalSettings(
settings: Settings,
loadedSettings: LoadedSettings,
+59
View File
@@ -1043,6 +1043,27 @@ const SETTINGS_SCHEMA = {
},
},
agent: {
type: 'object',
label: 'Agent',
category: 'Agent',
requiresRestart: false,
default: {},
description: 'Settings related to the active AI agent.',
showInDialog: false,
properties: {
name: {
type: 'string',
label: 'Agent Name',
category: 'Agent',
requiresRestart: false,
default: 'gemini-cli',
description: 'The active AI agent to use (gemini-cli or gemini-enterprise).',
showInDialog: true,
},
},
},
model: {
type: 'object',
label: 'Model',
@@ -1366,6 +1387,44 @@ const SETTINGS_SCHEMA = {
},
},
},
enterprise: {
type: 'object',
label: 'Gemini Enterprise',
category: 'Advanced',
requiresRestart: true,
default: {},
description: 'Settings for Gemini Enterprise integration.',
showInDialog: false,
properties: {
projectId: {
type: 'string',
label: 'Project ID',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Google Cloud Project ID for Gemini Enterprise.',
showInDialog: true,
},
engineId: {
type: 'string',
label: 'Engine ID',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description: 'Discovery Engine ID for Gemini Enterprise.',
showInDialog: true,
},
location: {
type: 'string',
label: 'Location',
category: 'Advanced',
requiresRestart: true,
default: 'global',
description: 'Google Cloud Location for Gemini Enterprise.',
showInDialog: true,
},
},
},
context: {
type: 'object',
+12 -8
View File
@@ -507,7 +507,8 @@ export async function main() {
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
let initialAuthFailed = false;
if (!settings.merged.security.auth.useExternal && !argv.isCommand) {
const useEnterprise = process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
if (!settings.merged.security.auth.useExternal && !argv.isCommand && !useEnterprise) {
try {
if (
partialConfig.isInteractive() &&
@@ -858,13 +859,16 @@ export async function main() {
),
);
const authType = await validateNonInteractiveAuth(
settings.merged.security.auth.selectedType,
settings.merged.security.auth.useExternal,
config,
settings,
);
await config.refreshAuth(authType);
const useEnterprise = process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
if (!useEnterprise) {
const authType = await validateNonInteractiveAuth(
settings.merged.security.auth.selectedType,
settings.merged.security.auth.useExternal,
config,
settings,
);
await config.refreshAuth(authType);
}
if (config.getDebugMode()) {
debugLogger.log('Session ID: %s', sessionId);
+13
View File
@@ -19,6 +19,7 @@ import {
type Config,
type ResumedSessionData,
coreEvents,
CoreEvent,
createWorkingStdio,
disableMouseEvents,
enableMouseEvents,
@@ -101,6 +102,17 @@ export async function startInteractiveUI(
// Create wrapper component to use hooks inside render
const AppWrapper = () => {
useKittyKeyboardProtocol();
const [agentKey, setAgentKey] = React.useState(() => config.getAgent());
React.useEffect(() => {
const handleAgentChanged = (payload: { agent: string }) => {
setAgentKey(payload.agent);
};
coreEvents.on(CoreEvent.AgentChanged, handleAgentChanged);
return () => {
coreEvents.off(CoreEvent.AgentChanged, handleAgentChanged);
};
}, []);
return (
<SettingsContext.Provider value={settings}>
@@ -113,6 +125,7 @@ export async function startInteractiveUI(
<SessionStatsProvider sessionId={config.getSessionId()}>
<VimModeProvider>
<AppContainer
key={agentKey}
config={config}
startupWarnings={startupWarnings}
version={version}
@@ -193,6 +193,7 @@ describe('runNonInteractive', () => {
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
getAgent: vi.fn().mockReturnValue('gemini-cli'),
} as unknown as Config;
mockSettings = {
+4 -1
View File
@@ -66,7 +66,10 @@ interface RunNonInteractiveParams {
export async function runNonInteractive(
params: RunNonInteractiveParams,
): Promise<void> {
const useAgentSession = params.config.getAgentSessionNoninteractiveEnabled();
const useAgentSession =
params.config.getAgent() === 'gemini-enterprise' ||
params.config.getAgentSessionNoninteractiveEnabled() ||
process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
if (useAgentSession) {
debugLogger.debug(
'[ADK] Running non-interactive mode with ADK agent session',
@@ -199,6 +199,7 @@ describe('runNonInteractive', () => {
getRawOutput: vi.fn().mockReturnValue(false),
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false),
getAgent: vi.fn().mockReturnValue('gemini-cli'),
} as unknown as Config;
mockSettings = {
@@ -35,6 +35,7 @@ import {
Scheduler,
ROOT_SCHEDULER_ID,
LegacyAgentSession,
EnterpriseAgentSession,
ToolErrorType,
geminiPartsToContentParts,
displayContentToString,
@@ -295,13 +296,18 @@ export async function runNonInteractive({
});
}
// Create LegacyAgentSession — owns the agentic loop
const session = new LegacyAgentSession({
client: geminiClient,
scheduler,
config,
promptId: prompt_id,
});
const useEnterprise =
config.getAgent() === 'gemini-enterprise' ||
process.env['GEMINI_CLI_ENTERPRISE_AGENT'] === 'true';
// Create AgentSession — owns the agentic loop
const session = useEnterprise
? new EnterpriseAgentSession({ config, promptId: prompt_id })
: new LegacyAgentSession({
client: geminiClient,
scheduler,
config,
promptId: prompt_id,
});
// Wire Ctrl+C to session abort
abortSession = () => {
@@ -19,6 +19,7 @@ import {
AuthType,
} from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentCommand } from '../ui/commands/agentCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
@@ -122,6 +123,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
agentCommand,
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
+3
View File
@@ -509,6 +509,7 @@ const baseMockUiState = {
terminalWidth: 100,
terminalHeight: 40,
currentModel: 'gemini-pro',
activeAgent: 'gemini-cli',
terminalBackgroundColor: 'black' as const,
cleanUiDetailsVisible: false,
allowPlanMode: true,
@@ -552,6 +553,8 @@ const mockUIActions: UIActions = {
exitPrivacyNotice: vi.fn(),
closeSettingsDialog: vi.fn(),
closeModelDialog: vi.fn(),
closeAgentDialog: vi.fn(),
handleAgentSelect: vi.fn(),
openVoiceModelDialog: vi.fn(),
closeVoiceModelDialog: vi.fn(),
openAgentConfigDialog: vi.fn(),
+84 -4
View File
@@ -90,6 +90,7 @@ import {
logBillingEvent,
ApiKeyUpdatedEvent,
LegacyAgentProtocol,
EnterpriseAgentProtocol,
type InjectionSource,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
@@ -102,6 +103,7 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useAgentCommand } from './hooks/useAgentCommand.js';
import { useVoiceModelCommand } from './hooks/useVoiceModelCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
@@ -146,6 +148,7 @@ import { relaunchApp } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useMcpStatus } from './hooks/useMcpStatus.js';
import { ConsentPrompt } from './components/ConsentPrompt.js';
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
@@ -937,6 +940,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
const { isAgentDialogOpen, openAgentDialog, closeAgentDialog } =
useAgentCommand();
const activeAgent = config?.getAgent() || 'gemini-cli';
const {
isVoiceModelDialogOpen,
@@ -967,6 +973,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openAgentDialog,
openVoiceModelDialog,
openAgentConfigDialog,
openPermissionsDialog,
@@ -1006,6 +1013,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openAgentDialog,
openVoiceModelDialog,
openAgentConfigDialog,
setQuittingMessages,
@@ -1173,11 +1181,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, [config]);
const streamAgent = useMemo(
() =>
config?.getAgentSessionInteractiveEnabled()
() => {
if (activeAgent === 'gemini-enterprise') {
return new EnterpriseAgentProtocol({ config });
}
return config?.getAgentSessionInteractiveEnabled()
? new LegacyAgentProtocol({ config, getPreferredEditor })
: undefined,
[config, getPreferredEditor],
: undefined;
},
[config, getPreferredEditor, activeAgent],
);
const activeStream = streamAgent
@@ -1233,6 +1245,65 @@ Logging in with Google... Restarting Gemini CLI to continue.
retryStatus,
} = activeStream;
const handleAgentSelect = useCallback(
(agentName: string) => {
const doSwap = () => {
config.setAgent(agentName, false);
// 1. Reset UI history
historyManager.clearItems();
refreshStatic();
setBannerVisible(false);
// 2. Reset streaming loop state
if (activeStream && 'reset' in activeStream) {
(activeStream as { reset: () => void }).reset();
}
};
const isSessionNonEmpty = historyManager.history.some(
(item) => item.type === 'user' || item.type === 'gemini',
);
if (isSessionNonEmpty) {
setCustomDialog(
<ConsentPrompt
prompt={`Switching agents will completely clear your active session history. Do you want to proceed to switch to ${agentName}?`}
onConfirm={(confirm: boolean) => {
setCustomDialog(null);
if (confirm) {
doSwap();
}
}}
terminalWidth={terminalWidth}
/>,
);
} else {
doSwap();
}
},
[
config,
historyManager,
refreshStatic,
setBannerVisible,
activeStream,
setCustomDialog,
terminalWidth,
],
);
useEffect(() => {
const handleAgentChanged = (payload: { agent: string }) => {
handleAgentSelect(payload.agent);
};
coreEvents.on(CoreEvent.AgentChanged, handleAgentChanged);
return () => {
coreEvents.off(CoreEvent.AgentChanged, handleAgentChanged);
};
}, [handleAgentSelect]);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
@@ -2210,6 +2281,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isAgentDialogOpen ||
isVoiceModelDialogOpen ||
isAgentConfigDialogOpen ||
isPermissionsDialogOpen ||
@@ -2471,6 +2543,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isAgentDialogOpen,
isVoiceModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
@@ -2521,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showApprovalModeIndicator,
allowPlanMode,
currentModel,
activeAgent,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -2584,6 +2658,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isAgentDialogOpen,
isVoiceModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
@@ -2655,6 +2730,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
ideTrustRestartReason,
isRestarting,
currentModel,
activeAgent,
extensionsUpdateState,
activePtyId,
backgroundTaskCount,
@@ -2700,6 +2776,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
closeAgentDialog,
handleAgentSelect,
openVoiceModelDialog,
closeVoiceModelDialog,
openAgentConfigDialog,
@@ -2802,6 +2880,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
closeAgentDialog,
handleAgentSelect,
openVoiceModelDialog,
closeVoiceModelDialog,
openAgentConfigDialog,
@@ -0,0 +1,139 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { agentCommand } from './agentCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import type { Config } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
describe('agentCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should return a dialog action to open the agent dialog when no args', async () => {
if (!agentCommand.action) {
throw new Error('The agent command must have an action.');
}
const result = await agentCommand.action(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'agent',
});
});
describe('manage subcommand', () => {
it('should return a dialog action to open the agent dialog', async () => {
const manageCommand = agentCommand.subCommands?.find(
(c) => c.name === 'manage',
);
expect(manageCommand).toBeDefined();
const result = await manageCommand!.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'agent',
});
});
});
describe('set subcommand', () => {
it('should set the agent and log the command', async () => {
const setCommand = agentCommand.subCommands?.find(
(c) => c.name === 'set',
);
expect(setCommand).toBeDefined();
const mockSetAgent = vi.fn();
mockContext.services.agentContext = {
setAgent: mockSetAgent,
get config() {
return this;
},
} as unknown as Config;
await setCommand!.action!(mockContext, 'gemini-enterprise');
expect(mockSetAgent).toHaveBeenCalledWith('gemini-enterprise', true);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Agent set to gemini-enterprise'),
}),
);
});
it('should set the agent with persistence when --persist is used', async () => {
const setCommand = agentCommand.subCommands?.find(
(c) => c.name === 'set',
);
const mockSetAgent = vi.fn();
mockContext.services.agentContext = {
setAgent: mockSetAgent,
get config() {
return this;
},
} as unknown as Config;
await setCommand!.action!(mockContext, 'gemini-cli --persist');
expect(mockSetAgent).toHaveBeenCalledWith('gemini-cli', false);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('Agent set to gemini-cli (persisted)'),
}),
);
});
it('should show error if no agent name is provided', async () => {
const setCommand = agentCommand.subCommands?.find(
(c) => c.name === 'set',
);
await setCommand!.action!(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: expect.stringContaining('Usage: /agent set <agent-name>'),
}),
);
});
it('should show error if invalid agent name is provided', async () => {
const setCommand = agentCommand.subCommands?.find(
(c) => c.name === 'set',
);
mockContext.services.agentContext = {
setAgent: vi.fn(),
get config() {
return this;
},
} as unknown as Config;
await setCommand!.action!(mockContext, 'invalid-agent');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: expect.stringContaining('Unknown agent: invalid-agent'),
}),
);
});
});
it('should have the correct name and description', () => {
expect(agentCommand.name).toBe('agent');
expect(agentCommand.description).toBe('Manage active AI agent configuration');
});
});
@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
CommandKind,
type SlashCommand,
} from './types.js';
import { MessageType } from '../types.js';
const setAgentCommand: SlashCommand = {
name: 'set',
description:
'Set the active agent to use. Usage: /agent set <agent-name> [--persist]',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context: CommandContext, args: string) => {
const parts = args.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Usage: /agent set <agent-name> [--persist]',
});
return;
}
const agentName = parts[0];
const persist = parts.includes('--persist');
if (context.services.agentContext?.config) {
if (agentName !== 'gemini-cli' && agentName !== 'gemini-enterprise') {
context.ui.addItem({
type: MessageType.ERROR,
text: `Unknown agent: ${agentName}. Valid agents are 'gemini-cli' and 'gemini-enterprise'.`,
});
return;
}
context.services.agentContext.config.setAgent(agentName, !persist);
context.ui.addItem({
type: MessageType.INFO,
text: `Agent set to ${agentName}${persist ? ' (persisted)' : ''}`,
});
}
},
};
const manageAgentCommand: SlashCommand = {
name: 'manage',
description: 'Opens a dialog to select the agent',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context: CommandContext) => {
return {
type: 'dialog',
dialog: 'agent',
};
},
};
export const agentCommand: SlashCommand = {
name: 'agent',
description: 'Manage active AI agent configuration',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [manageAgentCommand, setAgentCommand],
action: async (context: CommandContext, args: string) =>
manageAgentCommand.action!(context, args),
};
+1
View File
@@ -128,6 +128,7 @@ export interface OpenDialogActionReturn {
| 'settings'
| 'sessionBrowser'
| 'model'
| 'agent'
| 'voice-model'
| 'agentConfig'
| 'permissions';
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { AgentDialog } from './AgentDialog.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { createMockSettings } from '../../test-utils/settings.js';
import type { Config } from '@google/gemini-cli-core';
describe('<AgentDialog />', () => {
const mockSetAgent = vi.fn();
const mockGetAgent = vi.fn();
const mockOnClose = vi.fn();
const mockOnSelect = vi.fn();
interface MockConfig extends Partial<Config> {
setAgent: (agent: string, isTemporary?: boolean) => void;
getAgent: () => string;
getSessionId: () => string;
getIdeMode: () => boolean;
}
const mockConfig: MockConfig = {
setAgent: mockSetAgent,
getAgent: mockGetAgent,
getSessionId: () => 'test-session-id',
getIdeMode: () => false,
};
beforeEach(() => {
vi.resetAllMocks();
mockGetAgent.mockReturnValue('gemini-cli');
});
const renderComponent = async (configValue = mockConfig as Config) => {
const settings = createMockSettings({});
const result = await renderWithProviders(
<AgentDialog onClose={mockOnClose} onSelect={mockOnSelect} />,
{
config: configValue,
settings,
},
);
return result;
};
it('renders the agent selection view correctly', async () => {
const { lastFrame, unmount } = await renderComponent();
const output = lastFrame();
expect(output).toContain('Select Agent');
expect(output).toContain('Remember agent for future sessions: false');
expect(output).toContain('Gemini CLI (Standard)');
expect(output).toContain('Gemini Enterprise');
unmount();
});
it('selects agent and closes when an option is selected', async () => {
const { stdin, waitUntilReady, unmount } = await renderComponent();
// Select Gemini CLI (default selection is first item) by pressing Enter
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(mockSetAgent).toHaveBeenCalledWith('gemini-cli', true); // Ephemeral by default
expect(mockOnSelect).toHaveBeenCalledWith('gemini-cli');
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('selects the second agent when navigating and pressing enter', async () => {
const { stdin, waitUntilReady, unmount } = await renderComponent();
// Press arrow down to move selection to index 1 (gemini-enterprise)
await act(async () => {
stdin.write('\u001B[B'); // Arrow Down
});
await waitUntilReady();
// Press enter to select
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(mockSetAgent).toHaveBeenCalledWith('gemini-enterprise', true);
expect(mockOnSelect).toHaveBeenCalledWith('gemini-enterprise');
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('toggles persist mode with Tab key', async () => {
const { lastFrame, stdin, waitUntilReady, unmount } = await renderComponent();
expect(lastFrame()).toContain('Remember agent for future sessions: false');
// Press Tab to toggle persist mode
await act(async () => {
stdin.write('\t');
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Remember agent for future sessions: true');
});
// Select first item (gemini-cli) with persist mode active
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
await waitFor(() => {
expect(mockSetAgent).toHaveBeenCalledWith('gemini-cli', false); // Persist enabled (isTemporary = false)
expect(mockOnSelect).toHaveBeenCalledWith('gemini-cli');
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('closes dialog on escape key press', async () => {
const { stdin, waitUntilReady, unmount } = await renderComponent();
await act(async () => {
stdin.write('\u001B'); // Escape
});
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
});
@@ -0,0 +1,113 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
interface AgentDialogProps {
onClose: () => void;
onSelect: (agentName: string) => void;
}
export function AgentDialog({ onClose, onSelect }: AgentDialogProps): React.JSX.Element {
const config = useContext(ConfigContext);
const [persistMode, setPersistMode] = useState(false);
const preferredAgent = config?.getAgent() || 'gemini-cli';
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
return true;
}
if (key.name === 'tab') {
setPersistMode((prev) => !prev);
return true;
}
return false;
},
{ isActive: true },
);
const options = useMemo(() => {
return [
{
value: 'gemini-cli',
title: 'Gemini CLI (Standard)',
description: 'Standard model conversational chat with local tools execution.',
key: 'gemini-cli',
},
{
value: 'gemini-enterprise',
title: 'Gemini Enterprise',
description: 'Connected assistant grounded by your business data.',
key: 'gemini-enterprise',
},
];
}, []);
const initialIndex = useMemo(() => {
const idx = options.findIndex((option) => option.value === preferredAgent);
return idx !== -1 ? idx : 0;
}, [preferredAgent, options]);
const handleSelect = useCallback(
(agentName: string) => {
if (config) {
config.setAgent(agentName, persistMode ? false : true);
}
onSelect(agentName);
onClose();
},
[config, onSelect, onClose, persistMode],
);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>Select Agent</Text>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={options}
onSelect={handleSelect}
initialIndex={initialIndex}
showNumbers={true}
/>
</Box>
<Box marginTop={1} flexDirection="column">
<Box>
<Text bold color={theme.text.primary}>
Remember agent for future sessions:{' '}
</Text>
<Text color={theme.status.success}>
{persistMode ? 'true' : 'false'}
</Text>
<Text color={theme.text.secondary}> (Press Tab to toggle)</Text>
</Box>
</Box>
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{'> To use a specific agent on startup, use the --agent flag.'}
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>(Press Esc to close)</Text>
</Box>
</Box>
);
}
@@ -25,6 +25,7 @@ import { relaunchApp } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { AgentDialog } from './AgentDialog.js';
import { VoiceModelDialog } from './VoiceModelDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -240,6 +241,14 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.isAgentDialogOpen) {
return (
<AgentDialog
onClose={uiActions.closeAgentDialog}
onSelect={uiActions.handleAgentSelect}
/>
);
}
if (uiState.isVoiceModelDialogOpen) {
return <VoiceModelDialog onClose={uiActions.closeVoiceModelDialog} />;
}
+11
View File
@@ -198,6 +198,7 @@ export const Footer: React.FC = () => {
const {
model,
activeAgent,
targetDir,
debugMode,
branchName,
@@ -210,6 +211,7 @@ export const Footer: React.FC = () => {
terminalWidth,
} = {
model: uiState.currentModel,
activeAgent: uiState.activeAgent,
targetDir: config.getTargetDir(),
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
@@ -332,6 +334,15 @@ export const Footer: React.FC = () => {
);
break;
}
case 'agent-name': {
addCol(
id,
header,
() => <Text color={itemColor}>{activeAgent}</Text>,
activeAgent.length,
);
break;
}
case 'context-used': {
addCol(
id,
@@ -1,45 +1,49 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro limit reached
" workspace (/directory) sandbox /model /agent quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro gemini-cli limit reached
"
`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 85% used
" workspace (/directory) sandbox /model /agent quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro gemini-cli 85% used
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `
" workspace (/directory) sandbox /model context
...me/more/directories/to/make/it/long no sandbox gemini-pro 14%
" workspace (/directory) sandbox /model context /agent
...tories/to/make/it/long no sandbox gemini-pro 14% gemini-cli
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `
" workspace (/directory) sandbox /model context
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used
" workspace (/directory) sandbox /model context /agent
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 14% used gemini-cli
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `
" sandbox
no sandbox
" sandbox /agent
no sandbox gemini-cli
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `
" /agent
gemini-cli
"
`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `
" workspace (/directory) sandbox
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox
" workspace (/directory) sandbox /agent
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-cli
"
`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `
" workspace (/directory) sandbox /model quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro 15% used
" workspace (/directory) sandbox /model /agent quota
~/project/foo/bar/and/some/more/directories/to/make/it/long no sandbox gemini-pro gemini-cli 15% used
"
`;
@@ -41,6 +41,8 @@ export interface UIActions {
exitPrivacyNotice: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
closeAgentDialog: () => void;
handleAgentSelect: (agentName: string) => void;
openVoiceModelDialog: () => void;
closeVoiceModelDialog: () => void;
openAgentConfigDialog: (
@@ -114,6 +114,7 @@ export interface UIState {
isSettingsDialogOpen: boolean;
isSessionBrowserOpen: boolean;
isModelDialogOpen: boolean;
isAgentDialogOpen: boolean;
isVoiceModelDialogOpen: boolean;
isAgentConfigDialogOpen: boolean;
selectedAgentName?: string;
@@ -162,6 +163,7 @@ export interface UIState {
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
currentModel: string;
activeAgent: string;
contextFileNames: string[];
errorCount: number;
availableTerminalHeight: number | undefined;
@@ -205,6 +205,7 @@ describe('useSlashCommandProcessor', () => {
openSettingsDialog: vi.fn(),
openSessionBrowser: vi.fn(),
openModelDialog: mockOpenModelDialog,
openAgentDialog: vi.fn(),
openVoiceModelDialog: vi.fn(),
openAgentConfigDialog,
openPermissionsDialog: vi.fn(),
@@ -72,6 +72,7 @@ interface SlashCommandProcessorActions {
openSettingsDialog: () => void;
openSessionBrowser: () => void;
openModelDialog: () => void;
openAgentDialog: () => void;
openVoiceModelDialog: () => void;
openAgentConfigDialog: (
name: string,
@@ -506,6 +507,9 @@ export const useSlashCommandProcessor = (
case 'model':
actions.openModelDialog();
return { type: 'handled' };
case 'agent':
actions.openAgentDialog();
return { type: 'handled' };
case 'voice-model':
actions.openVoiceModelDialog();
return { type: 'handled' };
@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
interface UseAgentCommandReturn {
isAgentDialogOpen: boolean;
openAgentDialog: () => void;
closeAgentDialog: () => void;
}
export const useAgentCommand = (): UseAgentCommandReturn => {
const [isAgentDialogOpen, setIsAgentDialogOpen] = useState(false);
const openAgentDialog = useCallback(() => {
setIsAgentDialogOpen(true);
}, []);
const closeAgentDialog = useCallback(() => {
setIsAgentDialogOpen(false);
}, []);
return {
isAgentDialogOpen,
openAgentDialog,
closeAgentDialog,
};
};
@@ -569,8 +569,31 @@ export const useAgentStream = ({
[pendingHistoryItem, pendingToolGroupItems],
);
const reset = useCallback(() => {
setStreamingState(StreamingState.Idle);
setThought(null);
setPendingHistoryItem(null);
setTrackedTools([]);
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
setHasEmittedBoxInTurn(false);
currentStreamIdRef.current = null;
userMessageTimestampRef.current = 0;
geminiMessageBufferRef.current = '';
}, [
setStreamingState,
setThought,
setPendingHistoryItem,
setTrackedTools,
setPushedToolCallIds,
setIsFirstToolInGroup,
setHasEmittedBoxInTurn,
]);
return {
streamingState,
reset,
submitQuery,
initError,
pendingHistoryItems,
@@ -0,0 +1,253 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { EnterpriseAgentSession } from './enterprise-agent-session.js';
import type { Config } from '../config/config.js';
import type { AgentEvent } from './types.js';
import { GoogleAuth } from 'google-auth-library';
// Mock google-auth-library
vi.mock('google-auth-library', () => ({
GoogleAuth: vi.fn(),
}));
describe('EnterpriseAgentSession', () => {
let mockConfig: Config;
let globalFetch: typeof fetch;
beforeEach(() => {
vi.clearAllMocks();
(GoogleAuth as unknown as Mock).mockImplementation(() => ({
getClient: vi.fn().mockResolvedValue({
getAccessToken: vi.fn().mockResolvedValue({ token: 'fake-token' }),
}),
}));
mockConfig = {
getSessionId: vi.fn().mockReturnValue('test-session'),
getEnterpriseConfig: vi.fn().mockReturnValue({
projectId: 'test-project',
engineId: 'test-engine',
location: 'global',
}),
} as unknown as Config;
globalFetch = global.fetch;
});
afterEach(() => {
global.fetch = globalFetch;
vi.restoreAllMocks();
});
const mockFetchResponse = (chunks: string[]) => {
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
body: stream,
headers: new Headers(),
} as Response);
};
it('should successfully call Enterprise API and stream responses', async () => {
const chunk1 = JSON.stringify({
sessionInfo: { session: 'projects/test-project/locations/global/collections/default_collection/engines/test-engine/sessions/s1' },
answer: {
replies: [
{
groundedContent: {
content: { text: 'Hello' },
},
},
],
},
}) + '\n';
const chunk2 = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: { text: ' World' },
},
},
],
},
}) + '\n';
mockFetchResponse([chunk1, chunk2]);
const session = new EnterpriseAgentSession({ config: mockConfig });
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'hi' }] },
});
expect(streamId).toBe('enterprise-stream-1');
const events: AgentEvent[] = [];
for await (const event of session.stream({ streamId: streamId! })) {
events.push(event);
}
expect(events.map(e => e.type)).toEqual([
'agent_start',
'message', // Hello
'message', // World
'agent_end',
]);
const messages = events.filter((e): e is AgentEvent<'message'> => e.type === 'message' && e.role === 'agent');
expect(messages[0].content).toEqual([{ type: 'text', text: 'Hello' }]);
expect(messages[1].content).toEqual([{ type: 'text', text: ' World' }]);
});
it('should handle thoughts', async () => {
const chunk = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: { text: 'Thinking...', thought: true },
},
},
{
groundedContent: {
content: { text: 'Final answer' },
},
},
],
},
}) + '\n';
mockFetchResponse([chunk]);
const session = new EnterpriseAgentSession({ config: mockConfig });
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'hi' }] },
});
const events: AgentEvent[] = [];
for await (const event of session.stream({ streamId: streamId! })) {
events.push(event);
}
const thoughts = events.filter((e): e is AgentEvent<'message'> => e.type === 'message' && e.content[0]?.type === 'thought');
expect(thoughts).toHaveLength(1);
expect(thoughts[0].content).toEqual([{ type: 'thought', thought: 'Thinking...' }]);
const texts = events.filter((e): e is AgentEvent<'message'> => e.type === 'message' && e.content[0]?.type === 'text' && e.role === 'agent');
expect(texts).toHaveLength(1);
expect(texts[0].content).toEqual([{ type: 'text', text: 'Final answer' }]);
});
it('should handle tool requests and responses (executable code)', async () => {
const chunk1 = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: {
executableCode: { code: 'print("hello")' },
},
},
},
],
},
}) + '\n';
const chunk2 = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: {
codeExecutionResult: { outcome: 'OUTCOME_OK', output: 'hello\n' },
},
},
},
],
},
}) + '\n';
mockFetchResponse([chunk1, chunk2]);
const session = new EnterpriseAgentSession({ config: mockConfig });
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'run code' }] },
});
const events: AgentEvent[] = [];
for await (const event of session.stream({ streamId: streamId! })) {
events.push(event);
}
expect(events.map(e => e.type)).toEqual([
'agent_start',
'tool_request',
'tool_response',
'agent_end',
]);
const toolReq = events.find(e => e.type === 'tool_request') as AgentEvent<'tool_request'>;
expect(toolReq.name).toBe('python_interpreter');
expect(toolReq.args).toEqual({ code: 'print("hello")' });
const toolResp = events.find(e => e.type === 'tool_response') as AgentEvent<'tool_response'>;
expect(toolResp.name).toBe('python_interpreter');
expect(toolResp.content).toEqual([{ type: 'text', text: 'hello\n' }]);
expect(toolResp.isError).toBe(false);
});
it('should handle immersive artifacts (tables/docs)', async () => {
const chunk = JSON.stringify({
answer: {
replies: [
{
groundedContent: {
content: { text: 'Here is the table:\n' },
},
immersiveArtifact: [
{
docArtifact: { text: '| Col 1 | Col 2 |\n|---|---|\n| Val 1 | Val 2 |' },
},
],
},
],
},
}) + '\n';
mockFetchResponse([chunk]);
const session = new EnterpriseAgentSession({ config: mockConfig });
const { streamId } = await session.send({
message: { content: [{ type: 'text', text: 'show table' }] },
});
const events: AgentEvent[] = [];
for await (const event of session.stream({ streamId: streamId! })) {
events.push(event);
}
const texts = events.filter((e): e is AgentEvent<'message'> => e.type === 'message' && e.role === 'agent');
expect(texts).toHaveLength(2);
expect(texts[0].content).toEqual([{ type: 'text', text: 'Here is the table:\n' }]);
expect(texts[1].content).toEqual([{ type: 'text', text: '| Col 1 | Col 2 |\n|---|---|\n| Val 1 | Val 2 |' }]);
});
});
@@ -0,0 +1,420 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { GoogleAuth } from 'google-auth-library';
import type { Config } from '../config/config.js';
import { AgentSession } from './agent-session.js';
import type {
AgentEvent,
AgentProtocol,
AgentSend,
ContentPart,
Unsubscribe,
} from './types.js';
import { fetchWithTimeout } from '../utils/fetch.js';
import { debugLogger } from '../utils/debugLogger.js';
export interface EnterpriseAgentSessionDeps {
config: Config;
promptId?: string;
streamId?: string;
}
export class EnterpriseAgentProtocol implements AgentProtocol {
private _events: AgentEvent[] = [];
private _subscribers = new Set<(event: AgentEvent) => void>();
private _activeStreamId?: string;
private _abortController = new AbortController();
private _streamCounter = 0;
private _eventCounter = 0;
private readonly _config: Config;
private _sessionResourceName?: string;
constructor(deps: EnterpriseAgentSessionDeps) {
this._config = deps.config;
this._sessionResourceName = deps.streamId;
}
get events(): readonly AgentEvent[] {
return this._events;
}
subscribe(callback: (event: AgentEvent) => void): Unsubscribe {
this._subscribers.add(callback);
return () => {
this._subscribers.delete(callback);
};
}
async abort(): Promise<void> {
this._abortController.abort();
}
async send(payload: AgentSend): Promise<{ streamId: string }> {
const message = 'message' in payload ? payload.message : undefined;
if (!message) {
throw new Error(
'EnterpriseAgentSession.send() only supports message sends for the moment.',
);
}
if (this._activeStreamId) {
throw new Error(
'EnterpriseAgentSession.send() cannot be called while a stream is active.',
);
}
this._beginNewStream();
const streamId = this._activeStreamId!;
const userMessage = this._makeUserMessageEvent(
message.content,
message.displayContent,
payload._meta,
);
this._emit([userMessage]);
this._scheduleRunLoop(message.content.map(p => p.type === 'text' ? p.text : '').join(' '));
return { streamId };
}
private _beginNewStream(): void {
this._streamCounter++;
this._eventCounter = 0;
this._abortController = new AbortController();
this._activeStreamId = `enterprise-stream-${this._streamCounter}`;
}
private _scheduleRunLoop(queryText: string): void {
setTimeout(() => {
void this._runLoopInBackground(queryText);
}, 0);
}
private async _runLoopInBackground(queryText: string): Promise<void> {
this._ensureAgentStart();
try {
const enterpriseConfig = this._config.getEnterpriseConfig();
if (!enterpriseConfig?.projectId || !enterpriseConfig?.engineId) {
throw new Error('Gemini Enterprise is not fully configured. projectId and engineId are required in ~/.gemini/settings.json.');
}
const projectId = enterpriseConfig.projectId;
const engineId = enterpriseConfig.engineId;
const location = enterpriseConfig.location ?? 'global';
// Get Auth Token
const auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
});
const client = await auth.getClient();
const tokenResponse = await client.getAccessToken();
const token = tokenResponse.token;
if (!token) {
throw new Error('Failed to retrieve ADC access token.');
}
const endpoint = `https://discoveryengine.googleapis.com/v1alpha/projects/${projectId}/locations/${location}/collections/default_collection/engines/${engineId}/assistants/default_assistant:streamAssist`;
const requestBody = {
query: {
text: queryText,
},
session: this._sessionResourceName || '-',
assistSkippingMode: 'REQUEST_ASSIST',
toolsSpec: {
vertexAiSearchSpec: {},
webGroundingSpec: {},
},
};
debugLogger.debug(`Calling Enterprise API: ${endpoint}`);
debugLogger.debug(`Request Body: ${JSON.stringify(requestBody)}`);
const response = await fetchWithTimeout(endpoint, 60000, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: this._abortController.signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Enterprise API call failed with status ${response.status}: ${errorText}`);
}
if (!response.body) {
throw new Error('Response body is empty.');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let braceCount = 0;
let insideString = false;
let escapeNext = false;
let objectStart = -1;
let lastScannedIndex = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (let i = lastScannedIndex; i < buffer.length; i++) {
const char = buffer[i];
if (escapeNext) {
escapeNext = false;
lastScannedIndex = i + 1;
continue;
}
if (char === '\\') {
escapeNext = true;
lastScannedIndex = i + 1;
continue;
}
if (char === '"') {
insideString = !insideString;
lastScannedIndex = i + 1;
continue;
}
if (!insideString) {
if (char === '{') {
if (braceCount === 0) {
objectStart = i;
}
braceCount++;
} else if (char === '}') {
braceCount--;
if (braceCount === 0 && objectStart !== -1) {
const objectStr = buffer.substring(objectStart, i + 1);
await this._parseAndEmitChunk(objectStr);
// Remove parsed object from buffer and reset index
buffer = buffer.substring(i + 1);
i = -1;
lastScannedIndex = 0;
objectStart = -1;
}
}
}
lastScannedIndex = i + 1;
}
}
this._emit([this._makeAgentEndEvent('completed')]);
} catch (err: unknown) {
if (this._abortController.signal.aborted) {
this._emit([this._makeAgentEndEvent('aborted')]);
} else {
const message = err instanceof Error ? err.message : String(err);
this._emit([
this._makeErrorEvent({
status: 'INTERNAL',
message,
fatal: true,
}),
]);
this._emit([this._makeAgentEndEvent('failed')]);
}
} finally {
this._activeStreamId = undefined;
}
}
private _ensureAgentStart(): void {
// We can emit agent_start early or wait until we get the first chunk.
// For now, emit it at the start of background run.
this._emit([this._makeAgentStartEvent()]);
}
private async _parseAndEmitChunk(line: string): Promise<void> {
try {
const response = JSON.parse(line);
debugLogger.debug(`Received Enterprise Chunk: ${JSON.stringify(response)}`);
if (response.sessionInfo?.session) {
this._sessionResourceName = response.sessionInfo.session;
debugLogger.debug(`Session updated: ${this._sessionResourceName}`);
}
const answer = response.answer;
if (answer) {
if (answer.replies) {
for (const reply of answer.replies) {
if (reply.groundedContent?.content) {
const content = reply.groundedContent.content;
// Handle Thought
if (content.thought) {
if (content.text) {
this._emit([
this._makeMessageEvent('agent', [
{ type: 'thought', thought: content.text }
])
]);
}
}
// Handle Tool Use (Executable Code)
else if (content.executableCode) {
this._emit([
this._makeToolRequestEvent({
requestId: `ent-tool-${Date.now()}`,
name: 'python_interpreter',
args: { code: content.executableCode.code },
display: {
name: 'Python Interpreter',
description: 'Executing code server-side',
}
})
]);
}
// Handle Tool Response (Code Execution Result)
else if (content.codeExecutionResult) {
this._emit([
this._makeToolResponseEvent({
requestId: `ent-tool-${Date.now()}`,
name: 'python_interpreter',
content: [{ type: 'text', text: content.codeExecutionResult.output || '' }],
isError: content.codeExecutionResult.outcome === 'OUTCOME_FAILED',
})
]);
}
// Handle standard Text
else if (content.text) {
this._emit([
this._makeMessageEvent('agent', [
{ type: 'text', text: content.text }
])
]);
}
}
if (reply.immersiveArtifact) {
for (const artifact of reply.immersiveArtifact) {
if (artifact.docArtifact?.text) {
this._emit([
this._makeMessageEvent('agent', [
{ type: 'text', text: artifact.docArtifact.text }
])
]);
}
}
}
}
}
}
} catch (e) {
debugLogger.error(`Failed to parse line: ${line}`, e);
}
}
private _emit(events: AgentEvent[]): void {
if (events.length === 0) return;
const subscribers = [...this._subscribers];
for (const event of events) {
this._events.push(event);
for (const subscriber of subscribers) {
subscriber(event);
}
}
}
private _nextEventFields() {
return {
id: `${this._activeStreamId}-${this._eventCounter++}`,
timestamp: new Date().toISOString(),
streamId: this._activeStreamId!,
};
}
private _makeUserMessageEvent(
content: ContentPart[],
displayContent?: string,
meta?: Record<string, unknown>,
): AgentEvent<'message'> {
const eventContent: ContentPart[] = displayContent
? [{ type: 'text', text: displayContent }]
: content;
return {
...this._nextEventFields(),
type: 'message',
role: 'user',
content: eventContent,
...(meta ? { _meta: meta } : {}),
};
}
private _makeMessageEvent(
role: 'agent' | 'user' | 'developer',
content: ContentPart[],
): AgentEvent<'message'> {
return {
...this._nextEventFields(),
type: 'message',
role,
content,
};
}
private _makeAgentStartEvent(): AgentEvent<'agent_start'> {
return {
...this._nextEventFields(),
type: 'agent_start',
};
}
private _makeAgentEndEvent(reason: string): AgentEvent<'agent_end'> {
return {
...this._nextEventFields(),
type: 'agent_end',
reason,
};
}
private _makeErrorEvent(payload: Omit<AgentEvent<'error'>, 'id' | 'timestamp' | 'streamId' | 'type'>): AgentEvent<'error'> {
return {
...this._nextEventFields(),
type: 'error',
...payload,
};
}
private _makeToolRequestEvent(payload: Omit<AgentEvent<'tool_request'>, 'id' | 'timestamp' | 'streamId' | 'type'>): AgentEvent<'tool_request'> {
return {
...this._nextEventFields(),
type: 'tool_request',
...payload,
};
}
private _makeToolResponseEvent(payload: Omit<AgentEvent<'tool_response'>, 'id' | 'timestamp' | 'streamId' | 'type'>): AgentEvent<'tool_response'> {
return {
...this._nextEventFields(),
type: 'tool_response',
...payload,
};
}
}
export class EnterpriseAgentSession extends AgentSession {
constructor(deps: EnterpriseAgentSessionDeps) {
super(new EnterpriseAgentProtocol(deps));
}
}
+33
View File
@@ -208,6 +208,12 @@ export interface PlanSettings {
modelRouting?: boolean;
}
export interface EnterpriseSettings {
projectId?: string;
engineId?: string;
location?: string;
}
export interface TelemetrySettings {
enabled?: boolean;
traces?: boolean;
@@ -637,6 +643,7 @@ export interface ConfigParameters {
includeDirectories?: string[];
bugCommand?: BugCommandSettings;
model: string;
agent?: string;
disableLoopDetection?: boolean;
maxSessionTurns?: number;
acpMode?: boolean;
@@ -728,6 +735,7 @@ export interface ConfigParameters {
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
onModelChange?: (model: string) => void;
onAgentChange?: (agent: string) => void;
mcpEnabled?: boolean;
extensionsEnabled?: boolean;
agents?: AgentSettings;
@@ -742,6 +750,7 @@ export interface ConfigParameters {
};
vertexAiRouting?: VertexAiRoutingConfig;
logRagSnippets?: boolean;
enterprise?: EnterpriseSettings;
}
export class Config implements McpContext, AgentLoopContext {
@@ -824,6 +833,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly cwd: string;
private readonly bugCommand: BugCommandSettings | undefined;
private model: string;
private agent: string;
private readonly disableLoopDetection: boolean;
// null = unknown (quota not fetched); true = has access; false = definitively no access
private hasAccessToPreviewModel: boolean | null = null;
@@ -940,6 +950,7 @@ export class Config implements McpContext, AgentLoopContext {
private experimentsPromise: Promise<Experiments | undefined> | undefined;
private hookSystem?: HookSystem;
private readonly onModelChange: ((model: string) => void) | undefined;
private readonly onAgentChange: ((agent: string) => void) | undefined;
private readonly onReload:
| (() => Promise<{
disabledSkills?: string[];
@@ -978,12 +989,14 @@ export class Config implements McpContext, AgentLoopContext {
private lastModeSwitchTime: number = performance.now();
readonly injectionService: InjectionService;
private approvedPlanPath: string | undefined;
private readonly enterprise?: EnterpriseSettings;
constructor(params: ConfigParameters) {
this._sessionId = params.sessionId;
this.clientName = params.clientName;
this._clientVersion = params.clientVersion ?? 'unknown';
this.approvedPlanPath = undefined;
this.enterprise = params.enterprise;
this.embeddingModel =
params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL;
@@ -1117,6 +1130,7 @@ export class Config implements McpContext, AgentLoopContext {
this.fileDiscoveryService = params.fileDiscoveryService ?? null;
this.bugCommand = params.bugCommand;
this.model = params.model;
this.agent = params.agent ?? 'gemini-cli';
this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? true;
@@ -1381,6 +1395,7 @@ export class Config implements McpContext, AgentLoopContext {
this.experiments = params.experiments;
this.onModelChange = params.onModelChange;
this.onAgentChange = params.onAgentChange;
this.onReload = params.onReload;
this.billing = {
@@ -1817,6 +1832,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.clientName;
}
getEnterpriseConfig(): EnterpriseSettings | undefined {
return this.enterprise;
}
setSessionId(sessionId: string): void {
const previousPlansDir = this.storage.isInitialized()
? this.storage.getPlansDir()
@@ -1900,6 +1919,20 @@ export class Config implements McpContext, AgentLoopContext {
return this.model;
}
getAgent(): string {
return this.agent;
}
setAgent(newAgent: string, isTemporary = true): void {
if (this.agent !== newAgent) {
this.agent = newAgent;
coreEvents.emitAgentChanged(newAgent);
}
if (this.onAgentChange && !isTemporary) {
this.onAgentChange(newAgent);
}
}
getDisableLoopDetection(): boolean {
return this.disableLoopDetection ?? false;
}
+1
View File
@@ -196,6 +196,7 @@ export { resetBrowserSession } from './agents/browser/browserAgentFactory.js';
// Export agent session interface
export * from './agent/agent-session.js';
export * from './agent/legacy-agent-session.js';
export * from './agent/enterprise-agent-session.js';
export * from './agent/event-translator.js';
export * from './agent/content-utils.js';
export * from './agent/tool-display-utils.js';
+20
View File
@@ -53,6 +53,16 @@ export interface ModelChangedPayload {
model: string;
}
/**
* Payload for the 'agent-changed' event.
*/
export interface AgentChangedPayload {
/**
* The new active agent.
*/
agent: string;
}
/**
* Payload for the 'approval-mode-changed' event.
*/
@@ -196,6 +206,7 @@ export interface QuotaChangedPayload {
export enum CoreEvent {
UserFeedback = 'user-feedback',
ModelChanged = 'model-changed',
AgentChanged = 'agent-changed',
ApprovalModeChanged = 'approval-mode-changed',
ConsoleLog = 'console-log',
Output = 'output',
@@ -231,6 +242,7 @@ export interface EditorSelectedPayload {
export interface CoreEvents extends ExtensionEvents {
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
[CoreEvent.ModelChanged]: [ModelChangedPayload];
[CoreEvent.AgentChanged]: [AgentChangedPayload];
[CoreEvent.ApprovalModeChanged]: [ApprovalModeChangedPayload];
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
[CoreEvent.Output]: [OutputPayload];
@@ -344,6 +356,14 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.ModelChanged, payload);
}
/**
* Notifies subscribers that the active agent has changed.
*/
emitAgentChanged(agent: string): void {
const payload: AgentChangedPayload = { agent };
this.emit(CoreEvent.AgentChanged, payload);
}
/**
* Notifies subscribers that the approval mode has changed.
*/