mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c5f2708cc |
@@ -17,10 +17,9 @@ prefix.
|
||||
|
||||
**Example:** `!ls -la`
|
||||
|
||||
This executes `ls -la` immediately and prints the output to your terminal.
|
||||
Gemini CLI also records the command and its output in the current session
|
||||
context, so the model can reference it in follow-up prompts. Very large outputs
|
||||
may be truncated.
|
||||
This executes `ls -la` immediately and prints the output to your terminal. The
|
||||
AI doesn't "see" this output unless you paste it back into the chat or use it in
|
||||
a prompt.
|
||||
|
||||
### Scenario: Entering Shell mode
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ session's token usage, as well as your overall quota and usage for the supported
|
||||
models.
|
||||
|
||||
For more information on the `/stats` command and its subcommands, see the
|
||||
[Command Reference](../reference/commands.md#stats).
|
||||
[Command Reference](../../reference/commands.md#stats).
|
||||
|
||||
## Next steps
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ session's token usage, as well as information about the limits associated with
|
||||
your current quota.
|
||||
|
||||
For more information on the `/stats` command and its subcommands, see the
|
||||
[Command Reference](../reference/commands.md#stats).
|
||||
[Command Reference](../../reference/commands.md#stats).
|
||||
|
||||
A summary of model usage is also presented on exit at the end of a session.
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ export function setDeferredCommand(command: DeferredCommand) {
|
||||
deferredCommand = command;
|
||||
}
|
||||
|
||||
export function getDeferredCommand(): DeferredCommand | undefined {
|
||||
return deferredCommand;
|
||||
}
|
||||
|
||||
export async function runDeferredCommand(settings: MergedSettings) {
|
||||
if (!deferredCommand) {
|
||||
return;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
AuthType,
|
||||
fetchCachedCredentials,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type InitializationResult } from './core/initializer.js';
|
||||
@@ -130,6 +131,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
off: vi.fn(),
|
||||
drainBacklogs: vi.fn(),
|
||||
},
|
||||
fetchCachedCredentials: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -911,12 +913,47 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should exit with 41 for auth failure during sandbox setup', async () => {
|
||||
it('should skip refreshAuth in parent process when relaunch is expected', async () => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const refreshAuthSpy = vi.fn();
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
refreshAuth: refreshAuthSpy,
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'google', useExternal: false } },
|
||||
advanced: { autoConfigureMemory: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
|
||||
|
||||
// Initial process (no SANDBOX, no NO_RELAUNCH)
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(refreshAuthSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should exit with 41 for auth failure during sandbox setup when auth IS required', async () => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
// Force mustAuthNow = true by simulating sandbox entry without credentials
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
});
|
||||
vi.mocked(fetchCachedCredentials).mockResolvedValue(null);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
|
||||
@@ -931,7 +968,9 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
sandbox: true,
|
||||
} as unknown as CliArgs);
|
||||
|
||||
try {
|
||||
await main();
|
||||
|
||||
+43
-48
@@ -72,7 +72,6 @@ import {
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
initializeApp,
|
||||
@@ -108,8 +107,9 @@ import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { runDeferredCommand } from './deferred.js';
|
||||
import { runDeferredCommand, getDeferredCommand } from './deferred.js';
|
||||
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
||||
import { fetchCachedCredentials } from '@google/gemini-cli-core';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
@@ -328,13 +328,6 @@ export async function startInteractiveUI(
|
||||
export async function main() {
|
||||
const cliStartupHandle = startupProfiler.start('cli_startup');
|
||||
|
||||
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
|
||||
// sandbox mode, we re-fetch the admin controls from the server once we enter
|
||||
// the sandbox.
|
||||
// TODO: Cache settings in sandbox mode as well.
|
||||
const adminControlsListner = setupAdminControlsListener();
|
||||
registerCleanup(adminControlsListner.cleanup);
|
||||
|
||||
const cleanupStdio = patchStdio();
|
||||
registerSyncCleanup(() => {
|
||||
// This is needed to ensure we don't lose any buffered output.
|
||||
@@ -446,13 +439,50 @@ export async function main() {
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
|
||||
const isEnteringSandbox = !process.env['SANDBOX'] && !!argv.sandbox;
|
||||
|
||||
// We relaunch if we are not already in a sandbox and not already in a
|
||||
// relaunched process. relaunchAppInChildProcess ensures we always have a
|
||||
// child process that can be internally restarted.
|
||||
const willRelaunch =
|
||||
!process.env['SANDBOX'] && !process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
|
||||
// Determine if we MUST authenticate in the parent process
|
||||
const hasDeferredCommand = !!getDeferredCommand();
|
||||
let needsInteractiveSandboxLogin = false;
|
||||
|
||||
if (isEnteringSandbox && !settings.merged.security.auth.useExternal) {
|
||||
const authType =
|
||||
settings.merged.security.auth.selectedType ||
|
||||
(process.env['CLOUD_SHELL'] === 'true' ||
|
||||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'
|
||||
? AuthType.COMPUTE_ADC
|
||||
: AuthType.LOGIN_WITH_GOOGLE);
|
||||
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
const existingCredentials = await fetchCachedCredentials();
|
||||
if (!existingCredentials) {
|
||||
// Sandbox environment might block interactive login, so do it here
|
||||
needsInteractiveSandboxLogin = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mustAuthNow = hasDeferredCommand || needsInteractiveSandboxLogin;
|
||||
|
||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||
// the sandbox because the sandbox will interfere with the Oauth2 web
|
||||
// redirect.
|
||||
//
|
||||
// We skip this in the parent process if we are about to relaunch, as the
|
||||
// child process will perform its own authentication and fetch admin settings
|
||||
// itself.
|
||||
let initialAuthFailed = false;
|
||||
if (!settings.merged.security.auth.useExternal) {
|
||||
if (
|
||||
!settings.merged.security.auth.useExternal &&
|
||||
(!willRelaunch || mustAuthNow)
|
||||
) {
|
||||
try {
|
||||
if (
|
||||
partialConfig.isInteractive() &&
|
||||
@@ -501,6 +531,7 @@ export async function main() {
|
||||
}
|
||||
|
||||
// Run deferred command now that we have admin settings.
|
||||
// Note: if auth was skipped, settings.merged.admin will use defaults.
|
||||
await runDeferredCommand(settings.merged);
|
||||
|
||||
// hop into sandbox if we are outside and sandboxing is enabled
|
||||
@@ -558,7 +589,7 @@ export async function main() {
|
||||
} else {
|
||||
// Relaunch app so we always have a child process that can be internally
|
||||
// restarted if needed.
|
||||
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
|
||||
await relaunchAppInChildProcess(memoryArgs, []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,8 +608,6 @@ export async function main() {
|
||||
// access to the project identifier.
|
||||
await config.storage.initialize();
|
||||
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && settings.merged.general.devtools) {
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
@@ -870,37 +899,3 @@ export function initializeOutputListenersAndFlush() {
|
||||
}
|
||||
coreEvents.drainBacklogs();
|
||||
}
|
||||
|
||||
function setupAdminControlsListener() {
|
||||
let pendingSettings: AdminControlsSettings | undefined;
|
||||
let config: Config | undefined;
|
||||
|
||||
const messageHandler = (msg: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = msg as {
|
||||
type?: string;
|
||||
settings?: AdminControlsSettings;
|
||||
};
|
||||
if (message?.type === 'admin-settings' && message.settings) {
|
||||
if (config) {
|
||||
config.setRemoteAdminSettings(message.settings);
|
||||
} else {
|
||||
pendingSettings = message.settings;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.on('message', messageHandler);
|
||||
|
||||
return {
|
||||
setConfig: (newConfig: Config) => {
|
||||
config = newConfig;
|
||||
if (pendingSettings) {
|
||||
config.setRemoteAdminSettings(pendingSettings);
|
||||
}
|
||||
},
|
||||
cleanup: () => {
|
||||
process.off('message', messageHandler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,11 +131,6 @@ vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/utils/terminalSetup.js', () => ({
|
||||
shouldPromptForTerminalSetup: vi.fn().mockResolvedValue(false),
|
||||
useTerminalSetupPrompt: vi.fn(),
|
||||
}));
|
||||
|
||||
export interface AppRigOptions {
|
||||
fakeResponsesPath?: string;
|
||||
terminalWidth?: number;
|
||||
|
||||
@@ -203,10 +203,6 @@ vi.mock('../utils/events.js');
|
||||
vi.mock('../utils/handleAutoUpdate.js');
|
||||
vi.mock('./utils/ConsolePatcher.js');
|
||||
vi.mock('../utils/cleanup.js');
|
||||
vi.mock('./utils/terminalSetup.js', () => ({
|
||||
shouldPromptForTerminalSetup: vi.fn().mockResolvedValue(false),
|
||||
useTerminalSetupPrompt: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
|
||||
@@ -2484,15 +2484,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
onHintClear: () => {},
|
||||
onHintSubmit: () => {},
|
||||
handleRestart: async () => {
|
||||
if (process.send) {
|
||||
const remoteSettings = config.getRemoteAdminSettings();
|
||||
if (remoteSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
await relaunchApp();
|
||||
},
|
||||
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -12,12 +11,10 @@ import { relaunchApp } from '../../utils/processUtils.js';
|
||||
|
||||
interface LoginWithGoogleRestartDialogProps {
|
||||
onDismiss: () => void;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export const LoginWithGoogleRestartDialog = ({
|
||||
onDismiss,
|
||||
config,
|
||||
}: LoginWithGoogleRestartDialogProps) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
@@ -26,15 +23,6 @@ export const LoginWithGoogleRestartDialog = ({
|
||||
return true;
|
||||
} else if (key.name === 'r' || key.name === 'R') {
|
||||
setTimeout(async () => {
|
||||
if (process.send) {
|
||||
const remoteSettings = config.getRemoteAdminSettings();
|
||||
if (remoteSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
await relaunchApp();
|
||||
}, 100);
|
||||
return true;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
@@ -58,7 +58,6 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
|
||||
terminalWidth,
|
||||
timestamp,
|
||||
}) => {
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
@@ -84,53 +83,6 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
|
||||
option.value !== RewindOutcome.RevertOnly,
|
||||
);
|
||||
}, [stats]);
|
||||
if (isScreenReaderEnabled) {
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth}>
|
||||
<Text bold>Confirm Rewind</Text>
|
||||
|
||||
{stats && (
|
||||
<Box flexDirection="column">
|
||||
<Text>
|
||||
{stats.fileCount === 1
|
||||
? `File: ${stats.details?.at(0)?.fileName}`
|
||||
: `${stats.fileCount} files affected`}
|
||||
</Text>
|
||||
<Text>Lines added: {stats.addedLines}</Text>
|
||||
<Text>Lines removed: {stats.removedLines}</Text>
|
||||
{timestamp && <Text>({formatTimeAgo(timestamp)})</Text>}
|
||||
<Text>
|
||||
Note: Rewinding does not affect files edited manually or by the
|
||||
shell tool.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!stats && (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>No code changes to revert.</Text>
|
||||
{timestamp && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({formatTimeAgo(timestamp)})
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Text>Select an action:</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Use arrow keys to navigate, Enter to confirm, Esc to cancel.
|
||||
</Text>
|
||||
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { RewindViewer } from './RewindViewer.js';
|
||||
@@ -14,11 +14,6 @@ import type {
|
||||
MessageRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('ink', async () => {
|
||||
const actual = await vi.importActual<typeof import('ink')>('ink');
|
||||
return { ...actual, useIsScreenReaderEnabled: vi.fn(() => false) };
|
||||
});
|
||||
|
||||
vi.mock('./CliSpinner.js', () => ({
|
||||
CliSpinner: () => 'MockSpinner',
|
||||
}));
|
||||
@@ -76,35 +71,6 @@ describe('RewindViewer', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Screen Reader Accessibility', () => {
|
||||
beforeEach(async () => {
|
||||
const { useIsScreenReaderEnabled } = await import('ink');
|
||||
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { useIsScreenReaderEnabled } = await import('ink');
|
||||
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders the rewind viewer with conversation items', async () => {
|
||||
const conversation = createConversation([
|
||||
{ type: 'user', content: 'Hello', id: '1', timestamp: '1' },
|
||||
]);
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<RewindViewer
|
||||
conversation={conversation}
|
||||
onExit={vi.fn()}
|
||||
onRewind={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Rewind');
|
||||
expect(lastFrame()).toContain('Hello');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it.each([
|
||||
{ name: 'nothing interesting for empty conversation', messages: [] },
|
||||
@@ -434,31 +400,3 @@ describe('RewindViewer', () => {
|
||||
unmount2();
|
||||
});
|
||||
});
|
||||
it('renders accessible screen reader view when screen reader is enabled', async () => {
|
||||
const { useIsScreenReaderEnabled } = await import('ink');
|
||||
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
|
||||
|
||||
const messages: MessageRecord[] = [
|
||||
{ type: 'user', content: 'Hello world', id: '1', timestamp: '1' },
|
||||
{ type: 'user', content: 'Second message', id: '2', timestamp: '2' },
|
||||
];
|
||||
const conversation = createConversation(messages);
|
||||
const onExit = vi.fn();
|
||||
const onRewind = vi.fn();
|
||||
|
||||
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
|
||||
<RewindViewer
|
||||
conversation={conversation}
|
||||
onExit={onExit}
|
||||
onRewind={onRewind}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Rewind - Select a conversation point:');
|
||||
expect(frame).toContain('Stay at current position');
|
||||
|
||||
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
type ConversationRecord,
|
||||
@@ -50,7 +50,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
}) => {
|
||||
const [isRewinding, setIsRewinding] = useState(false);
|
||||
const { terminalWidth, terminalHeight } = useUIState();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
const {
|
||||
selectedMessageId,
|
||||
getStats,
|
||||
@@ -129,6 +128,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
5,
|
||||
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
|
||||
);
|
||||
|
||||
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
|
||||
|
||||
if (selectedMessageId) {
|
||||
@@ -182,41 +182,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isScreenReaderEnabled) {
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth}>
|
||||
<Text bold>Rewind - Select a conversation point:</Text>
|
||||
<BaseSelectionList
|
||||
items={items}
|
||||
initialIndex={items.length - 1}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
wrapAround={false}
|
||||
onSelect={(item: MessageRecord) => {
|
||||
if (item?.id) {
|
||||
if (item.id === 'current-position') {
|
||||
onExit();
|
||||
} else {
|
||||
selectMessage(item.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
renderItem={(itemWrapper) => {
|
||||
const item = itemWrapper.value;
|
||||
const text =
|
||||
item.id === 'current-position'
|
||||
? 'Stay at current position'
|
||||
: getCleanedRewindText(item);
|
||||
return <Text>{text}</Text>;
|
||||
}}
|
||||
/>
|
||||
<Text color={theme.text.secondary}>
|
||||
Press Esc to exit, Enter to select, arrow keys to navigate.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { RELAUNCH_EXIT_CODE } from './processUtils.js';
|
||||
import {
|
||||
writeToStderr,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { writeToStderr } from '@google/gemini-cli-core';
|
||||
|
||||
export async function relaunchOnExitCode(runner: () => Promise<number>) {
|
||||
while (true) {
|
||||
@@ -34,14 +31,11 @@ export async function relaunchOnExitCode(runner: () => Promise<number>) {
|
||||
export async function relaunchAppInChildProcess(
|
||||
additionalNodeArgs: string[],
|
||||
additionalScriptArgs: string[],
|
||||
remoteAdminSettings?: AdminControlsSettings,
|
||||
) {
|
||||
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
|
||||
return;
|
||||
}
|
||||
|
||||
let latestAdminSettings = remoteAdminSettings;
|
||||
|
||||
const runner = () => {
|
||||
// process.argv is [node, script, ...args]
|
||||
// We want to construct [ ...nodeArgs, script, ...scriptArgs]
|
||||
@@ -61,20 +55,10 @@ export async function relaunchAppInChildProcess(
|
||||
process.stdin.pause();
|
||||
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
env: newEnv,
|
||||
});
|
||||
|
||||
if (latestAdminSettings) {
|
||||
child.send({ type: 'admin-settings', settings: latestAdminSettings });
|
||||
}
|
||||
|
||||
child.on('message', (msg: { type?: string; settings?: unknown }) => {
|
||||
if (msg.type === 'admin-settings-update' && msg.settings) {
|
||||
latestAdminSettings = msg.settings as AdminControlsSettings;
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
|
||||
@@ -31,7 +31,6 @@ describe('agent-scheduler', () => {
|
||||
mockConfig = {
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getAgentsSettings: vi.fn().mockReturnValue({}),
|
||||
} as unknown as Mocked<Config>;
|
||||
});
|
||||
|
||||
@@ -48,7 +47,6 @@ describe('agent-scheduler', () => {
|
||||
|
||||
const options = {
|
||||
schedulerId: 'subagent-1',
|
||||
agentName: 'mock-agent',
|
||||
parentCallId: 'parent-1',
|
||||
toolRegistry: mockToolRegistry as unknown as ToolRegistry,
|
||||
signal: new AbortController().signal,
|
||||
|
||||
@@ -19,8 +19,6 @@ import type { EditorType } from '../utils/editor.js';
|
||||
export interface AgentSchedulingOptions {
|
||||
/** The unique ID for this agent's scheduler. */
|
||||
schedulerId: string;
|
||||
/** The name of the agent executing the tools. */
|
||||
agentName: string;
|
||||
/** The ID of the tool call that invoked this agent. */
|
||||
parentCallId?: string;
|
||||
/** The tool registry specific to this agent. */
|
||||
@@ -48,7 +46,6 @@ export async function scheduleAgentTools(
|
||||
): Promise<CompletedToolCall[]> {
|
||||
const {
|
||||
schedulerId,
|
||||
agentName,
|
||||
parentCallId,
|
||||
toolRegistry,
|
||||
signal,
|
||||
@@ -61,16 +58,6 @@ export async function scheduleAgentTools(
|
||||
const agentConfig: Config = Object.create(config);
|
||||
agentConfig.getToolRegistry = () => toolRegistry;
|
||||
|
||||
// Apply any subagent-specific approval mode override, fallback to the main config mode.
|
||||
const overrideMode =
|
||||
config.getAgentsSettings()?.overrides?.[agentName]?.approvalMode;
|
||||
if (overrideMode) {
|
||||
agentConfig.getApprovalMode = () => overrideMode;
|
||||
} else {
|
||||
// Subagents follow the global approval mode by default.
|
||||
agentConfig.getApprovalMode = () => config.getApprovalMode();
|
||||
}
|
||||
|
||||
const scheduler = new Scheduler({
|
||||
config: agentConfig,
|
||||
messageBus: config.getMessageBus(),
|
||||
|
||||
@@ -1073,7 +1073,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
toolRequests,
|
||||
{
|
||||
schedulerId: this.agentId,
|
||||
agentName: this.definition.name,
|
||||
parentCallId: this.parentCallId,
|
||||
toolRegistry: this.toolRegistry,
|
||||
signal,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type ModelConfig,
|
||||
ModelConfigService,
|
||||
} from '../services/modelConfigService.js';
|
||||
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js';
|
||||
|
||||
/**
|
||||
* Returns the model config alias for a given agent definition.
|
||||
@@ -314,6 +315,39 @@ export class AgentRegistry {
|
||||
this.agents.set(mergedDefinition.name, mergedDefinition);
|
||||
|
||||
this.registerModelConfigs(mergedDefinition);
|
||||
this.addAgentPolicy(mergedDefinition);
|
||||
}
|
||||
|
||||
private addAgentPolicy(definition: AgentDefinition<z.ZodTypeAny>): void {
|
||||
const policyEngine = this.config.getPolicyEngine();
|
||||
if (!policyEngine) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user has explicitly defined a policy for this tool, respect it.
|
||||
// ignoreDynamic=true means we only check for rules NOT added by this registry.
|
||||
if (policyEngine.hasRuleForTool(definition.name, true)) {
|
||||
if (this.config.getDebugMode()) {
|
||||
debugLogger.log(
|
||||
`[AgentRegistry] User policy exists for '${definition.name}', skipping dynamic registration.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up any old dynamic policy for this tool (e.g. if we are overwriting an agent)
|
||||
policyEngine.removeRulesForTool(definition.name, 'AgentRegistry (Dynamic)');
|
||||
|
||||
// Add the new dynamic policy
|
||||
policyEngine.addRule({
|
||||
toolName: definition.name,
|
||||
decision:
|
||||
definition.kind === 'local'
|
||||
? PolicyDecision.ALLOW
|
||||
: PolicyDecision.ASK_USER,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
source: 'AgentRegistry (Dynamic)',
|
||||
});
|
||||
}
|
||||
|
||||
private isAgentEnabled<TOutput extends z.ZodTypeAny>(
|
||||
@@ -427,6 +461,7 @@ export class AgentRegistry {
|
||||
);
|
||||
}
|
||||
this.agents.set(definition.name, definition);
|
||||
this.addAgentPolicy(definition);
|
||||
} catch (e) {
|
||||
const errorMessage = `Error loading A2A agent "${definition.name}": ${e instanceof Error ? e.message : String(e)}`;
|
||||
debugLogger.warn(`[AgentRegistry] ${errorMessage}`, e);
|
||||
|
||||
@@ -52,7 +52,6 @@ export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
|
||||
messageBus,
|
||||
/* isOutputMarkdown */ true,
|
||||
/* canUpdateOutput */ true,
|
||||
/* toolAnnotations */ { isSubagent: true, agentKind: definition.kind },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -642,7 +642,7 @@ export function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchCachedCredentials(): Promise<
|
||||
export async function fetchCachedCredentials(): Promise<
|
||||
Credentials | JWTInput | null
|
||||
> {
|
||||
const useEncryptedStorage = getUseEncryptedStorageFlag();
|
||||
|
||||
@@ -234,7 +234,6 @@ export interface AgentOverride {
|
||||
modelConfig?: ModelConfig;
|
||||
runConfig?: AgentRunConfig;
|
||||
enabled?: boolean;
|
||||
approvalMode?: ApprovalMode;
|
||||
}
|
||||
|
||||
export interface AgentSettings {
|
||||
|
||||
@@ -728,23 +728,6 @@ describe('Gemini Client (client.ts)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('yields UserCancelled when processTurn throws AbortError', async () => {
|
||||
const abortError = new Error('Aborted');
|
||||
abortError.name = 'AbortError';
|
||||
vi.spyOn(client['loopDetector'], 'turnStarted').mockRejectedValueOnce(
|
||||
abortError,
|
||||
);
|
||||
|
||||
const stream = client.sendMessageStream(
|
||||
[{ text: 'Hi' }],
|
||||
new AbortController().signal,
|
||||
'prompt-id-abort-error',
|
||||
);
|
||||
const events = await fromAsync(stream);
|
||||
|
||||
expect(events).toEqual([{ type: GeminiEventType.UserCancelled }]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
compressionStatus:
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
type RetryAvailabilityContext,
|
||||
} from '../utils/retry.js';
|
||||
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import { getErrorMessage, isAbortError } from '../utils/errors.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { tokenLimit } from './tokenLimits.js';
|
||||
import type {
|
||||
ChatRecordingService,
|
||||
@@ -957,12 +957,6 @@ export class GeminiClient {
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isAbortError(error)) {
|
||||
yield { type: GeminiEventType.UserCancelled };
|
||||
return turn;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
const hookState = this.hookStateMap.get(prompt_id);
|
||||
if (hookState) {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Default policy for dynamically registered local subagents
|
||||
[[rule]]
|
||||
toolAnnotations = { isSubagent = true, agentKind = "local" }
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
# Default policy for dynamically registered remote subagents
|
||||
[[rule]]
|
||||
toolAnnotations = { isSubagent = true, agentKind = "remote" }
|
||||
decision = "ask_user"
|
||||
priority = 50
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type SafetyCheckerRule,
|
||||
InProcessCheckerType,
|
||||
ApprovalMode,
|
||||
PRIORITY_SUBAGENT_TOOL,
|
||||
} from './types.js';
|
||||
import type { FunctionCall } from '@google/genai';
|
||||
import { SafetyCheckDecision } from '../safety/protocol.js';
|
||||
@@ -1594,7 +1595,7 @@ describe('PolicyEngine', () => {
|
||||
{
|
||||
toolName: 'unknown_subagent',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 1.05,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
PolicyDecision,
|
||||
ApprovalMode,
|
||||
PRIORITY_SUBAGENT_TOOL,
|
||||
} from './types.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
@@ -910,27 +911,12 @@ priority = 100
|
||||
|
||||
it('should override default subagent rules when in Plan Mode for unknown subagents', async () => {
|
||||
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
|
||||
const planFileContent = await fs.readFile(planTomlPath, 'utf-8');
|
||||
|
||||
const subagentsTomlPath = path.resolve(
|
||||
__dirname,
|
||||
'policies',
|
||||
'subagents.toml',
|
||||
);
|
||||
const subagentsFileContent = await fs.readFile(subagentsTomlPath, 'utf-8');
|
||||
|
||||
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
|
||||
const tempPolicyDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'plan-policy-test-'),
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(tempPolicyDir, 'plan.toml'),
|
||||
planFileContent,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(tempPolicyDir, 'subagents.toml'),
|
||||
subagentsFileContent,
|
||||
);
|
||||
await fs.writeFile(path.join(tempPolicyDir, 'plan.toml'), fileContent);
|
||||
const getPolicyTier = () => 1; // Default tier
|
||||
|
||||
// 1. Load the actual Plan Mode policies
|
||||
@@ -946,14 +932,20 @@ priority = 100
|
||||
});
|
||||
|
||||
// 3. Simulate an unknown Subagent being registered (Dynamic Rule)
|
||||
// Now handled by subagents.toml policy using toolAnnotations
|
||||
const checkResult = await engine.check(
|
||||
{ name: 'unknown_subagent' },
|
||||
{ isSubagent: true, agentKind: 'local' },
|
||||
);
|
||||
engine.addRule({
|
||||
toolName: 'unknown_subagent',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_SUBAGENT_TOOL,
|
||||
source: 'AgentRegistry (Dynamic)',
|
||||
});
|
||||
|
||||
// 4. Verify Behavior:
|
||||
// The Plan Mode "Catch-All Deny" (from plan.toml) should override the Subagent Allow
|
||||
const checkResult = await engine.check(
|
||||
{ name: 'unknown_subagent' },
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(
|
||||
checkResult.decision,
|
||||
'Unknown subagent should be DENIED in Plan Mode',
|
||||
|
||||
@@ -301,3 +301,8 @@ export interface CheckResult {
|
||||
rule?: PolicyRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority for subagent tools (registered dynamically).
|
||||
* Effective priority matching Tier 1 (Default) read-only tools.
|
||||
*/
|
||||
export const PRIORITY_SUBAGENT_TOOL = 1.05;
|
||||
|
||||
@@ -606,13 +606,12 @@ function workflowStepResearch(options: PrimaryWorkflowsOptions): string {
|
||||
}
|
||||
|
||||
function workflowStepStrategy(options: PrimaryWorkflowsOptions): string {
|
||||
if (options.approvedPlan && options.taskTracker) {
|
||||
return `2. **Strategy:** An approved plan is available for this task. Treat this file as your single source of truth and invoke the task tracker tool to create tasks for this plan. You MUST read this file before proceeding. If you discover new requirements or need to change the approach, confirm with the user and update this plan file to reflect the updated design decisions or discovered requirements. Make sure to update the tracker task list based on this updated plan. Once all implementation and verification steps are finished, provide a **final summary** of the work completed against the plan and offer clear **next steps** to the user (e.g., 'Open a pull request').`;
|
||||
}
|
||||
|
||||
if (options.approvedPlan) {
|
||||
return `2. **Strategy:** An approved plan is available for this task. Treat this file as your single source of truth. You MUST read this file before proceeding. If you discover new requirements or need to change the approach, confirm with the user and update this plan file to reflect the updated design decisions or discovered requirements. Once all implementation and verification steps are finished, provide a **final summary** of the work completed against the plan and offer clear **next steps** to the user (e.g., 'Open a pull request').`;
|
||||
}
|
||||
if (options.approvedPlan && options.taskTracker) {
|
||||
return `2. **Strategy:** An approved plan is available for this task. Treat this file as your single source of truth and invoke the task tracker tool to create tasks for this plan. You MUST read this file before proceeding. If you discover new requirements or need to change the approach, confirm with the user and update this plan file to reflect the updated design decisions or discovered requirements. Make sure to update the tracker task list based on this updated plan. Once all implementation and verification steps are finished, provide a **final summary** of the work completed against the plan and offer clear **next steps** to the user (e.g., 'Open a pull request').`;
|
||||
}
|
||||
|
||||
if (options.enableWriteTodosTool) {
|
||||
return `2. **Strategy:** Formulate a grounded plan based on your research.${
|
||||
|
||||
@@ -54,23 +54,7 @@ async function resolveExistingRgPath(): Promise<string | null> {
|
||||
}
|
||||
|
||||
let ripgrepAcquisitionPromise: Promise<string | null> | null = null;
|
||||
/**
|
||||
* Ensures a ripgrep binary is available.
|
||||
*
|
||||
* NOTE:
|
||||
* - The Gemini CLI currently prefers a managed ripgrep binary downloaded
|
||||
* into its global bin directory.
|
||||
* - Even if ripgrep is available on the system PATH, it is intentionally
|
||||
* not used at this time.
|
||||
*
|
||||
* Preference for system-installed ripgrep is blocked on:
|
||||
* - checksum verification of external binaries
|
||||
* - internalization of the get-ripgrep dependency
|
||||
*
|
||||
* See:
|
||||
* - feat(core): Prefer rg in system path (#11847)
|
||||
* - Move get-ripgrep to third_party (#12099)
|
||||
*/
|
||||
|
||||
async function ensureRipgrepAvailable(): Promise<string | null> {
|
||||
const existingPath = await resolveExistingRgPath();
|
||||
if (existingPath) {
|
||||
|
||||
@@ -228,6 +228,12 @@ export const ALL_BUILTIN_TOOL_NAMES = [
|
||||
GET_INTERNAL_DOCS_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
TRACKER_GET_TASK_TOOL_NAME,
|
||||
TRACKER_LIST_TASKS_TOOL_NAME,
|
||||
TRACKER_ADD_DEPENDENCY_TOOL_NAME,
|
||||
TRACKER_VISUALIZE_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -123,19 +123,7 @@ describe('partUtils', () => {
|
||||
|
||||
it('should return descriptive string for inlineData part', () => {
|
||||
const part = { inlineData: { mimeType: 'image/png', data: '' } } as Part;
|
||||
expect(partToString(part, verboseOptions)).toBe(
|
||||
'[Image: image/png, 0.0 KB]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show size for inlineData with non-empty base64 data', () => {
|
||||
// 4 base64 chars → ceil(4*3/4) = 3 bytes → 3/1024 ≈ 0.0 KB
|
||||
const part = {
|
||||
inlineData: { mimeType: 'audio/mp3', data: 'AAAA' },
|
||||
} as Part;
|
||||
expect(partToString(part, verboseOptions)).toBe(
|
||||
'[Audio: audio/mp3, 0.0 KB]',
|
||||
);
|
||||
expect(partToString(part, verboseOptions)).toBe('<image/png>');
|
||||
});
|
||||
|
||||
it('should return an empty string for an unknown part type', () => {
|
||||
@@ -154,7 +142,7 @@ describe('partUtils', () => {
|
||||
],
|
||||
];
|
||||
expect(partToString(parts as Part, verboseOptions)).toBe(
|
||||
'start middle[Function Call: func1] end[Audio: audio/mp3, 0.0 KB]',
|
||||
'start middle[Function Call: func1] end<audio/mp3>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,18 +63,7 @@ export function partToString(
|
||||
return `[Function Response: ${part.functionResponse.name}]`;
|
||||
}
|
||||
if (part.inlineData !== undefined) {
|
||||
const mimeType = part.inlineData.mimeType ?? 'unknown';
|
||||
const data = part.inlineData.data ?? '';
|
||||
const bytes = Math.ceil((data.length * 3) / 4);
|
||||
const kb = (bytes / 1024).toFixed(1);
|
||||
const category = mimeType.startsWith('audio/')
|
||||
? 'Audio'
|
||||
: mimeType.startsWith('video/')
|
||||
? 'Video'
|
||||
: mimeType.startsWith('image/')
|
||||
? 'Image'
|
||||
: 'Media';
|
||||
return `[${category}: ${mimeType}, ${kb} KB]`;
|
||||
return `<${part.inlineData.mimeType}>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,6 @@ export class GeminiCliSession {
|
||||
toolCallsToSchedule,
|
||||
{
|
||||
schedulerId: sessionId,
|
||||
agentName: 'sdk-agent',
|
||||
toolRegistry: scopedRegistry,
|
||||
signal: abortSignal,
|
||||
},
|
||||
|
||||
@@ -2241,11 +2241,6 @@
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to enable the agent."
|
||||
},
|
||||
"approvalMode": {
|
||||
"type": "string",
|
||||
"description": "Approval mode for this subagent's internal tool calls.",
|
||||
"enum": ["default", "yolo", "plan", "autoEdit"]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user