mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 03:00:58 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ced2f2873d |
@@ -837,7 +837,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.enableEventDrivenScheduler`** (boolean):
|
||||
- **Description:** Enables event-driven scheduler within the CLI session.
|
||||
- **Default:** `true`
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.extensionReloading`** (boolean):
|
||||
@@ -909,7 +909,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **`hooksConfig.enabled`** (boolean):
|
||||
- **Description:** Canonical toggle for the hooks system. When disabled, no
|
||||
hooks will be executed.
|
||||
- **Default:** `true`
|
||||
- **Default:** `false`
|
||||
|
||||
- **`hooksConfig.disabled`** (array):
|
||||
- **Description:** List of hook names (commands) that should be disabled.
|
||||
|
||||
@@ -511,5 +511,8 @@ describe('migrate command', () => {
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
|
||||
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,6 +244,9 @@ export async function handleMigrateFromClaude() {
|
||||
debugLogger.log(
|
||||
'\nMigration complete! Please review the migrated hooks in .gemini/settings.json',
|
||||
);
|
||||
debugLogger.log(
|
||||
'Note: Set hooksConfig.enabled to true in your settings to enable the hook system.',
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error(`Error saving migrated hooks: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ export async function loadCliConfig(
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks:
|
||||
(settings.tools?.enableHooks ?? true) &&
|
||||
(settings.hooksConfig?.enabled ?? true),
|
||||
(settings.hooksConfig?.enabled ?? false),
|
||||
enableHooksUI: settings.tools?.enableHooks ?? true,
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
|
||||
@@ -68,6 +68,10 @@ import {
|
||||
ExtensionSettingScope,
|
||||
} from './extensions/extensionSettings.js';
|
||||
import type { EventEmitter } from 'node:stream';
|
||||
import { glob } from 'glob';
|
||||
import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js';
|
||||
import { McpPromptLoader } from '../services/McpPromptLoader.js';
|
||||
import { FileCommandLoader } from '../services/FileCommandLoader.js';
|
||||
|
||||
interface ExtensionManagerParams {
|
||||
enabledExtensionOverrides?: string[];
|
||||
@@ -236,6 +240,9 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
|
||||
await this.checkCommandConflicts(localSourcePath, newExtensionName);
|
||||
|
||||
const previous = this.getExtensions().find(
|
||||
(installed) => installed.name === newExtensionName,
|
||||
);
|
||||
@@ -899,6 +906,84 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
await this.maybeStartExtension(extension);
|
||||
}
|
||||
|
||||
private async checkCommandConflicts(
|
||||
localSourcePath: string,
|
||||
extensionName: string,
|
||||
): Promise<void> {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
// 1. Get current commands
|
||||
const currentLoaders = [
|
||||
new McpPromptLoader(this.config ?? null),
|
||||
new BuiltinCommandLoader(this.config ?? null),
|
||||
new FileCommandLoader(this.config ?? null),
|
||||
];
|
||||
|
||||
const currentCommandsResults = await Promise.allSettled(
|
||||
currentLoaders.map((l) => l.loadCommands(signal)),
|
||||
);
|
||||
const currentCommandNames = new Set<string>();
|
||||
for (const result of currentCommandsResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
result.value.forEach((cmd) => {
|
||||
// If it's an update, don't count existing commands from the SAME extension as conflicts
|
||||
if (cmd.extensionName !== extensionName) {
|
||||
currentCommandNames.add(cmd.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get commands from the new/updated extension
|
||||
const extensionCommandsDir = path.join(localSourcePath, 'commands');
|
||||
if (!fs.existsSync(extensionCommandsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await glob('**/*.toml', {
|
||||
cwd: extensionCommandsDir,
|
||||
nodir: true,
|
||||
dot: true,
|
||||
follow: true,
|
||||
});
|
||||
|
||||
const conflicts: Array<{
|
||||
commandName: string;
|
||||
renamedName: string;
|
||||
}> = [];
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = file.substring(0, file.length - 5); // length of '.toml'
|
||||
const baseCommandName = relativePath
|
||||
.split(path.sep)
|
||||
.map((segment) => segment.replaceAll(':', '_'))
|
||||
.join(':');
|
||||
|
||||
if (currentCommandNames.has(baseCommandName)) {
|
||||
conflicts.push({
|
||||
commandName: baseCommandName,
|
||||
renamedName: `${extensionName}.${baseCommandName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
const conflictList = conflicts
|
||||
.map(
|
||||
(c) =>
|
||||
` - '/${c.commandName}' (will be renamed to '/${c.renamedName}')`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const warning = `WARNING: Installing extension '${extensionName}' will cause the following command conflicts:\n${conflictList}\n\nDo you want to continue installation?`;
|
||||
|
||||
if (!(await this.requestConsent(warning))) {
|
||||
throw new Error('Installation cancelled due to command conflicts.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
|
||||
|
||||
@@ -872,7 +872,6 @@ describe('extension tests', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(tempWorkspaceDir).merged;
|
||||
settings.hooksConfig.enabled = false;
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('SettingsSchema', () => {
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.default).toBe(false);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
|
||||
@@ -1429,7 +1429,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Event Driven Scheduler',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
default: false,
|
||||
description: 'Enables event-driven scheduler within the CLI session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
@@ -1646,7 +1646,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
default: false,
|
||||
description:
|
||||
'Canonical toggle for the hooks system. When disabled, no hooks will be executed.',
|
||||
showInDialog: false,
|
||||
|
||||
@@ -728,7 +728,6 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { CommandService } from './CommandService.js';
|
||||
import { type ICommandLoader } from './types.js';
|
||||
import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
|
||||
name,
|
||||
@@ -37,6 +37,7 @@ class MockCommandLoader implements ICommandLoader {
|
||||
describe('CommandService', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||
CommandService.clearEmittedFeedbacksForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -237,6 +238,32 @@ describe('CommandService', () => {
|
||||
expect(syncExtension?.extensionName).toBe('git-helper');
|
||||
});
|
||||
|
||||
it('should emit feedback when an extension command is renamed', async () => {
|
||||
const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN);
|
||||
const extensionCommand = {
|
||||
...createMockCommand('deploy', CommandKind.FILE),
|
||||
extensionName: 'firebase',
|
||||
description: '[firebase] Deploy to Firebase',
|
||||
};
|
||||
|
||||
const mockLoader1 = new MockCommandLoader([builtinCommand]);
|
||||
const mockLoader2 = new MockCommandLoader([extensionCommand]);
|
||||
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
|
||||
await CommandService.create(
|
||||
[mockLoader1, mockLoader2],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledWith(
|
||||
'info',
|
||||
expect.stringContaining(
|
||||
"Extension command '/deploy' from 'firebase' was renamed to '/firebase.deploy'",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle user/project command override correctly', async () => {
|
||||
const builtinCommand = createMockCommand('help', CommandKind.BUILT_IN);
|
||||
const userCommand = createMockCommand('help', CommandKind.FILE);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
|
||||
import type { SlashCommand } from '../ui/commands/types.js';
|
||||
import type { ICommandLoader } from './types.js';
|
||||
|
||||
@@ -20,6 +20,16 @@ import type { ICommandLoader } from './types.js';
|
||||
* system to be extended with new sources without modifying the service itself.
|
||||
*/
|
||||
export class CommandService {
|
||||
private static emittedFeedbacks = new Set<string>();
|
||||
|
||||
/**
|
||||
* Clears the set of emitted feedback messages.
|
||||
* This should ONLY be used in tests to ensure isolation between test cases.
|
||||
*/
|
||||
static clearEmittedFeedbacksForTest(): void {
|
||||
CommandService.emittedFeedbacks.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Private constructor to enforce the use of the async factory.
|
||||
* @param commands A readonly array of the fully loaded and de-duplicated commands.
|
||||
@@ -77,6 +87,12 @@ export class CommandService {
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const feedbackMsg = `Extension command '/${cmd.name}' from '${cmd.extensionName}' was renamed to '/${renamedName}' due to a conflict with an existing command.`;
|
||||
if (!CommandService.emittedFeedbacks.has(feedbackMsg)) {
|
||||
coreEvents.emitFeedback('info', feedbackMsg);
|
||||
CommandService.emittedFeedbacks.add(feedbackMsg);
|
||||
}
|
||||
|
||||
finalName = renamedName;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ interface CommandDirectory {
|
||||
* Defines the Zod schema for a command definition file. This serves as the
|
||||
* single source of truth for both validation and type inference.
|
||||
*/
|
||||
const TomlCommandDefSchema = z.object({
|
||||
export const TomlCommandDefSchema = z.object({
|
||||
prompt: z.string({
|
||||
required_error: "The 'prompt' field is required.",
|
||||
invalid_type_error: "The 'prompt' field must be a string.",
|
||||
|
||||
@@ -20,7 +20,6 @@ import { cleanup } from 'ink-testing-library';
|
||||
import { act, useContext, type ReactElement } from 'react';
|
||||
import { AppContainer } from './AppContainer.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import { type TrackedToolCall } from './hooks/useReactToolScheduler.js';
|
||||
import {
|
||||
type Config,
|
||||
makeFakeConfig,
|
||||
@@ -1275,12 +1274,8 @@ describe('AppContainer State Management', () => {
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime + 100, // Trigger aggressive delay
|
||||
retryStatus: null,
|
||||
lastOutputTime: 0,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
@@ -1314,136 +1309,6 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show Working… in title for redirected commands after 2 mins', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty with redirection active
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [
|
||||
{
|
||||
request: {
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'ls > out' },
|
||||
},
|
||||
status: 'executing',
|
||||
} as unknown as TrackedToolCall,
|
||||
],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime,
|
||||
retryStatus: null,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
|
||||
|
||||
const { unmount } = renderAppContainer({
|
||||
settings: mockSettingsWithTitleEnabled,
|
||||
});
|
||||
|
||||
// Fast-forward time by 65 seconds - should still NOT be Action Required
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(65000);
|
||||
});
|
||||
|
||||
const titleWritesMid = mocks.mockStdout.write.mock.calls.filter(
|
||||
(call) => call[0].includes('\x1b]0;'),
|
||||
);
|
||||
expect(titleWritesMid[titleWritesMid.length - 1][0]).not.toContain(
|
||||
'✋ Action Required',
|
||||
);
|
||||
|
||||
// Fast-forward to 2 minutes (120000ms)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
});
|
||||
|
||||
const titleWritesEnd = mocks.mockStdout.write.mock.calls.filter(
|
||||
(call) => call[0].includes('\x1b]0;'),
|
||||
);
|
||||
expect(titleWritesEnd[titleWritesEnd.length - 1][0]).toContain(
|
||||
'⏲ Working…',
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show Working… in title for silent non-redirected commands after 1 min', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
// Arrange: Set up mock settings with showStatusInTitle enabled
|
||||
const mockSettingsWithTitleEnabled = {
|
||||
...mockSettings,
|
||||
merged: {
|
||||
...mockSettings.merged,
|
||||
ui: {
|
||||
...mockSettings.merged.ui,
|
||||
showStatusInTitle: true,
|
||||
hideWindowTitle: false,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty with NO output since operation started (silent)
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: { subject: 'Executing shell command' },
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
pendingToolCalls: [],
|
||||
handleApprovalModeChange: vi.fn(),
|
||||
activePtyId: 'pty-1',
|
||||
loopDetectionConfirmationRequest: null,
|
||||
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
|
||||
retryStatus: null,
|
||||
});
|
||||
|
||||
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true);
|
||||
|
||||
const { unmount } = renderAppContainer({
|
||||
settings: mockSettingsWithTitleEnabled,
|
||||
});
|
||||
|
||||
// Fast-forward time by 65 seconds
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(65000);
|
||||
});
|
||||
|
||||
const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) =>
|
||||
call[0].includes('\x1b]0;'),
|
||||
);
|
||||
const lastTitle = titleWrites[titleWrites.length - 1][0];
|
||||
// Should show Working… (⏲) instead of Action Required (✋)
|
||||
expect(lastTitle).toContain('⏲ Working…');
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT show Action Required in title if shell is streaming output', async () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
@@ -1462,7 +1327,7 @@ describe('AppContainer State Management', () => {
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
// Mock an active shell pty but not focused
|
||||
let lastOutputTime = startTime + 1000;
|
||||
let lastOutputTime = 1000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
@@ -1488,7 +1353,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Update lastOutputTime to simulate new output
|
||||
lastOutputTime = startTime + 21000;
|
||||
lastOutputTime = 21000;
|
||||
mockedUseGeminiStream.mockImplementation(() => ({
|
||||
streamingState: 'responding',
|
||||
submitQuery: vi.fn(),
|
||||
|
||||
@@ -62,9 +62,7 @@ import {
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
generateSummary,
|
||||
|
||||
type AgentDefinition,
|
||||
type AgentsDiscoveredPayload} from '@google/gemini-cli-core';
|
||||
} from '@google/gemini-cli-core';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import process from 'node:process';
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
@@ -96,7 +94,6 @@ import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
import { useFolderTrust } from './hooks/useFolderTrust.js';
|
||||
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
|
||||
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
|
||||
@@ -130,12 +127,10 @@ import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
} from './constants.js';
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import {
|
||||
NewAgentsNotification,
|
||||
NewAgentsChoice,
|
||||
} from './components/NewAgentsNotification.js';
|
||||
import { useInactivityTimer } from './hooks/useInactivityTimer.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
return pendingHistoryItems.some((item) => {
|
||||
@@ -210,8 +205,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
null,
|
||||
);
|
||||
|
||||
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||
|
||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
const [bannerVisible, setBannerVisible] = useState(true);
|
||||
@@ -378,20 +371,14 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
setAdminSettingsChanged(true);
|
||||
};
|
||||
|
||||
const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => {
|
||||
setNewAgents(payload.agents);
|
||||
};
|
||||
|
||||
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||
coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged);
|
||||
coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
|
||||
return () => {
|
||||
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||
coreEvents.off(
|
||||
CoreEvent.AdminSettingsChanged,
|
||||
handleAdminSettingsChanged,
|
||||
);
|
||||
coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -827,7 +814,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingHistoryItems: pendingGeminiHistoryItems,
|
||||
thought,
|
||||
cancelOngoingRequest,
|
||||
pendingToolCalls,
|
||||
handleApprovalModeChange,
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
@@ -859,17 +845,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
lastOutputTimeRef.current = lastOutputTime;
|
||||
}, [lastOutputTime]);
|
||||
|
||||
const { shouldShowFocusHint, inactivityStatus } = useShellInactivityStatus({
|
||||
activePtyId,
|
||||
const isShellAwaitingFocus =
|
||||
!!activePtyId &&
|
||||
!embeddedShellFocused &&
|
||||
config.isInteractiveShellEnabled();
|
||||
const showShellActionRequired = useInactivityTimer(
|
||||
isShellAwaitingFocus,
|
||||
lastOutputTime,
|
||||
streamingState,
|
||||
pendingToolCalls,
|
||||
embeddedShellFocused,
|
||||
isInteractiveShellEnabled: config.isInteractiveShellEnabled(),
|
||||
});
|
||||
|
||||
const shouldShowActionRequiredTitle = inactivityStatus === 'action_required';
|
||||
const shouldShowSilentWorkingTitle = inactivityStatus === 'silent_working';
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
);
|
||||
|
||||
// Auto-accept indicator
|
||||
const showApprovalModeIndicator = useApprovalModeIndicator({
|
||||
@@ -1251,11 +1235,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[handleSlashCommand, settings],
|
||||
);
|
||||
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
|
||||
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
settings.merged.ui.customWittyPhrases,
|
||||
!!activePtyId && !embeddedShellFocused,
|
||||
lastOutputTime,
|
||||
retryStatus,
|
||||
});
|
||||
);
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key) => {
|
||||
@@ -1385,8 +1371,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const paddedTitle = computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject: thought?.subject,
|
||||
isConfirming: !!confirmationRequest || shouldShowActionRequiredTitle,
|
||||
isSilentWorking: shouldShowSilentWorkingTitle,
|
||||
isConfirming: !!confirmationRequest || showShellActionRequired,
|
||||
folderName: basename(config.getTargetDir()),
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
@@ -1402,8 +1387,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
streamingState,
|
||||
thought,
|
||||
confirmationRequest,
|
||||
shouldShowActionRequiredTitle,
|
||||
shouldShowSilentWorkingTitle,
|
||||
showShellActionRequired,
|
||||
settings.merged.ui.showStatusInTitle,
|
||||
settings.merged.ui.dynamicWindowTitle,
|
||||
settings.merged.ui.hideWindowTitle,
|
||||
@@ -1850,26 +1834,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
);
|
||||
}
|
||||
|
||||
if (newAgents) {
|
||||
const handleNewAgentsSelect = (choice: NewAgentsChoice) => {
|
||||
if (choice === NewAgentsChoice.ACKNOWLEDGE) {
|
||||
const registry = config.getAgentRegistry();
|
||||
newAgents.forEach((agent) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
registry.acknowledgeAgent(agent);
|
||||
});
|
||||
}
|
||||
setNewAgents(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<NewAgentsNotification
|
||||
agents={newAgents}
|
||||
onSelect={handleNewAgentsSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { type AgentDefinition } from '@google/gemini-cli-core';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './shared/RadioButtonSelect.js';
|
||||
|
||||
export enum NewAgentsChoice {
|
||||
ACKNOWLEDGE = 'acknowledge',
|
||||
IGNORE = 'ignore',
|
||||
}
|
||||
|
||||
interface NewAgentsNotificationProps {
|
||||
agents: AgentDefinition[];
|
||||
onSelect: (choice: NewAgentsChoice) => void;
|
||||
}
|
||||
|
||||
export const NewAgentsNotification = ({
|
||||
agents,
|
||||
onSelect,
|
||||
}: NewAgentsNotificationProps) => {
|
||||
const options: Array<RadioSelectItem<NewAgentsChoice>> = [
|
||||
{
|
||||
label: 'Acknowledge and Enable',
|
||||
value: NewAgentsChoice.ACKNOWLEDGE,
|
||||
key: 'acknowledge',
|
||||
},
|
||||
{
|
||||
label: 'Do not enable (Ask again next time)',
|
||||
value: NewAgentsChoice.IGNORE,
|
||||
key: 'ignore',
|
||||
},
|
||||
];
|
||||
|
||||
// Limit display to 5 agents to avoid overflow, show count for rest
|
||||
const displayAgents = agents.slice(0, 5);
|
||||
const remaining = agents.length - 5;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
New Agents Discovered
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
The following agents were found in this project. Please review them:
|
||||
</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
marginBottom={1}
|
||||
borderStyle="single"
|
||||
padding={1}
|
||||
>
|
||||
{displayAgents.map((agent) => (
|
||||
<Box key={agent.name} flexDirection="column" marginBottom={1}>
|
||||
<Text bold>- {agent.name}</Text>
|
||||
<Text color="gray"> {agent.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<Text color="gray">... and {remaining} more.</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={onSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ export interface Suggestion {
|
||||
description?: string;
|
||||
matchedIndex?: number;
|
||||
commandKind?: CommandKind;
|
||||
extensionName?: string;
|
||||
}
|
||||
interface SuggestionsDisplayProps {
|
||||
suggestions: Suggestion[];
|
||||
@@ -65,8 +66,13 @@ export function SuggestionsDisplay({
|
||||
[CommandKind.AGENT]: ' [Agent]',
|
||||
};
|
||||
|
||||
const getFullLabel = (s: Suggestion) =>
|
||||
s.label + (s.commandKind ? (COMMAND_KIND_SUFFIX[s.commandKind] ?? '') : '');
|
||||
const getFullLabel = (s: Suggestion) => {
|
||||
let label = s.label;
|
||||
if (s.commandKind && COMMAND_KIND_SUFFIX[s.commandKind]) {
|
||||
label += COMMAND_KIND_SUFFIX[s.commandKind];
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const maxLabelLength = Math.max(
|
||||
...suggestions.map((s) => getFullLabel(s).length),
|
||||
|
||||
@@ -44,6 +44,12 @@ describe('ToolConfirmationMessage Redirection', () => {
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toMatchSnapshot();
|
||||
expect(output).toContain('echo "hello" > test.txt');
|
||||
expect(output).toContain(
|
||||
'Note: Command contains redirection which can be undesirable.',
|
||||
);
|
||||
expect(output).toContain(
|
||||
'Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToolConfirmationMessage Redirection > should display redirection warning and tip for redirected commands 1`] = `
|
||||
"echo "hello" > test.txt
|
||||
|
||||
Note: Command contains redirection which can be undesirable.
|
||||
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
|
||||
|
||||
Allow execution of: 'echo, redirection (>)'?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
3. No, suggest changes (esc)
|
||||
"
|
||||
`;
|
||||
@@ -32,7 +32,6 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10;
|
||||
export const WARNING_PROMPT_DURATION_MS = 1000;
|
||||
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
|
||||
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
|
||||
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000;
|
||||
|
||||
export const KEYBOARD_SHORTCUTS_URL =
|
||||
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should prioritize interactive shell waiting over normal waiting immediately 2`] = `"Interactive shell awaiting input... press tab to focus shell"`;
|
||||
|
||||
exports[`usePhraseCycler > should reset phrase when transitioning from waiting to active 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should show "Waiting for user confirmation..." when isWaiting is true 1`] = `"Waiting for user confirmation..."`;
|
||||
|
||||
exports[`usePhraseCycler > should show interactive shell waiting message immediately when isInteractiveShellWaiting is true 1`] = `"Interactive shell awaiting input... press tab to focus shell"`;
|
||||
@@ -31,30 +31,36 @@ describe('useLoadingIndicator', () => {
|
||||
|
||||
const renderLoadingIndicatorHook = (
|
||||
initialStreamingState: StreamingState,
|
||||
initialShouldShowFocusHint: boolean = false,
|
||||
initialIsInteractiveShellWaiting: boolean = false,
|
||||
initialLastOutputTime: number = 0,
|
||||
initialRetryStatus: RetryAttemptPayload | null = null,
|
||||
) => {
|
||||
let hookResult: ReturnType<typeof useLoadingIndicator>;
|
||||
function TestComponent({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
isInteractiveShellWaiting,
|
||||
lastOutputTime,
|
||||
retryStatus,
|
||||
}: {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
isInteractiveShellWaiting?: boolean;
|
||||
lastOutputTime?: number;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
}) {
|
||||
hookResult = useLoadingIndicator({
|
||||
hookResult = useLoadingIndicator(
|
||||
streamingState,
|
||||
shouldShowFocusHint: !!shouldShowFocusHint,
|
||||
retryStatus: retryStatus || null,
|
||||
});
|
||||
undefined,
|
||||
isInteractiveShellWaiting,
|
||||
lastOutputTime,
|
||||
retryStatus,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const { rerender } = render(
|
||||
<TestComponent
|
||||
streamingState={initialStreamingState}
|
||||
shouldShowFocusHint={initialShouldShowFocusHint}
|
||||
isInteractiveShellWaiting={initialIsInteractiveShellWaiting}
|
||||
lastOutputTime={initialLastOutputTime}
|
||||
retryStatus={initialRetryStatus}
|
||||
/>,
|
||||
);
|
||||
@@ -66,7 +72,8 @@ describe('useLoadingIndicator', () => {
|
||||
},
|
||||
rerender: (newProps: {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint?: boolean;
|
||||
isInteractiveShellWaiting?: boolean;
|
||||
lastOutputTime?: number;
|
||||
retryStatus?: RetryAttemptPayload | null;
|
||||
}) => rerender(<TestComponent {...newProps} />),
|
||||
};
|
||||
@@ -81,11 +88,12 @@ describe('useLoadingIndicator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should show interactive shell waiting phrase when shouldShowFocusHint is true', async () => {
|
||||
it('should show interactive shell waiting phrase when isInteractiveShellWaiting is true after 5s', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
const { result, rerender } = renderLoadingIndicatorHook(
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
true,
|
||||
1,
|
||||
);
|
||||
|
||||
// Initially should be witty phrase or tip
|
||||
@@ -94,10 +102,7 @@ describe('useLoadingIndicator', () => {
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
streamingState: StreamingState.Responding,
|
||||
shouldShowFocusHint: true,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toBe(
|
||||
@@ -219,10 +224,12 @@ describe('useLoadingIndicator', () => {
|
||||
const { result } = renderLoadingIndicatorHook(
|
||||
StreamingState.Responding,
|
||||
false,
|
||||
0,
|
||||
retryStatus,
|
||||
);
|
||||
|
||||
expect(result.current.currentLoadingPhrase).toContain('Trying to reach');
|
||||
expect(result.current.currentLoadingPhrase).toContain('Attempt 3/3');
|
||||
expect(result.current.currentLoadingPhrase).toBe(
|
||||
'Trying to reach gemini-pro (Retry 2/2)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,19 +13,13 @@ import {
|
||||
type RetryAttemptPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
export interface UseLoadingIndicatorProps {
|
||||
streamingState: StreamingState;
|
||||
shouldShowFocusHint: boolean;
|
||||
retryStatus: RetryAttemptPayload | null;
|
||||
customWittyPhrases?: string[];
|
||||
}
|
||||
|
||||
export const useLoadingIndicator = ({
|
||||
streamingState,
|
||||
shouldShowFocusHint,
|
||||
retryStatus,
|
||||
customWittyPhrases,
|
||||
}: UseLoadingIndicatorProps) => {
|
||||
export const useLoadingIndicator = (
|
||||
streamingState: StreamingState,
|
||||
customWittyPhrases?: string[],
|
||||
isInteractiveShellWaiting: boolean = false,
|
||||
lastOutputTime: number = 0,
|
||||
retryStatus: RetryAttemptPayload | null = null,
|
||||
) => {
|
||||
const [timerResetKey, setTimerResetKey] = useState(0);
|
||||
const isTimerActive = streamingState === StreamingState.Responding;
|
||||
|
||||
@@ -36,7 +30,8 @@ export const useLoadingIndicator = ({
|
||||
const currentLoadingPhrase = usePhraseCycler(
|
||||
isPhraseCyclingActive,
|
||||
isWaiting,
|
||||
shouldShowFocusHint,
|
||||
isInteractiveShellWaiting,
|
||||
lastOutputTime,
|
||||
customWittyPhrases,
|
||||
);
|
||||
|
||||
@@ -66,7 +61,7 @@ export const useLoadingIndicator = ({
|
||||
}, [streamingState, elapsedTimeFromTimer]);
|
||||
|
||||
const retryPhrase = retryStatus
|
||||
? `Trying to reach ${getDisplayString(retryStatus.model)} (Attempt ${retryStatus.attempt + 1}/${retryStatus.maxAttempts})`
|
||||
? `Trying to reach ${getDisplayString(retryStatus.model)} (Retry ${retryStatus.attempt}/${retryStatus.maxAttempts - 1})`
|
||||
: null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Text } from 'ink';
|
||||
import {
|
||||
usePhraseCycler,
|
||||
PHRASE_CHANGE_INTERVAL_MS,
|
||||
INTERACTIVE_SHELL_WAITING_PHRASE,
|
||||
} from './usePhraseCycler.js';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
@@ -20,17 +21,20 @@ const TestComponent = ({
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting = false,
|
||||
lastOutputTime = 0,
|
||||
customPhrases,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
isWaiting: boolean;
|
||||
isInteractiveShellWaiting?: boolean;
|
||||
lastOutputTime?: number;
|
||||
customPhrases?: string[];
|
||||
}) => {
|
||||
const phrase = usePhraseCycler(
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting,
|
||||
lastOutputTime,
|
||||
customPhrases,
|
||||
);
|
||||
return <Text>{phrase}</Text>;
|
||||
@@ -61,10 +65,11 @@ describe('usePhraseCycler', () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toBe('Waiting for user confirmation...');
|
||||
});
|
||||
|
||||
it('should show interactive shell waiting message immediately when isInteractiveShellWaiting is true', async () => {
|
||||
it('should show interactive shell waiting message when isInteractiveShellWaiting is true after 5s', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
const { lastFrame, rerender } = render(
|
||||
<TestComponent isActive={true} isWaiting={false} />,
|
||||
);
|
||||
@@ -73,34 +78,90 @@ describe('usePhraseCycler', () => {
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
isInteractiveShellWaiting={true}
|
||||
lastOutputTime={1}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
// Should still be showing a witty phrase or tip initially
|
||||
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
|
||||
lastFrame(),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE);
|
||||
});
|
||||
|
||||
it('should prioritize interactive shell waiting over normal waiting immediately', async () => {
|
||||
it('should reset interactive shell waiting timer when lastOutputTime changes', async () => {
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty
|
||||
const { lastFrame, rerender } = render(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
isInteractiveShellWaiting={true}
|
||||
lastOutputTime={1000}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Advance 3 seconds
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
// Should still be witty phrase or tip
|
||||
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
|
||||
lastFrame(),
|
||||
);
|
||||
|
||||
// Update lastOutputTime
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={false}
|
||||
isInteractiveShellWaiting={true}
|
||||
lastOutputTime={4000}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Advance another 3 seconds (total 6s from start, but only 3s from last output)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
});
|
||||
// Should STILL be witty phrase or tip because timer reset
|
||||
expect([...WITTY_LOADING_PHRASES, ...INFORMATIVE_TIPS]).toContain(
|
||||
lastFrame(),
|
||||
);
|
||||
|
||||
// Advance another 2 seconds (total 5s from last output)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
});
|
||||
expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE);
|
||||
});
|
||||
|
||||
it('should prioritize interactive shell waiting over normal waiting after 5s', async () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
<TestComponent isActive={true} isWaiting={true} />,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toBe('Waiting for user confirmation...');
|
||||
|
||||
rerender(
|
||||
<TestComponent
|
||||
isActive={true}
|
||||
isWaiting={true}
|
||||
isInteractiveShellWaiting={true}
|
||||
lastOutputTime={1}
|
||||
/>,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toBe(INTERACTIVE_SHELL_WAITING_PHRASE);
|
||||
});
|
||||
|
||||
it('should not cycle phrases if isActive is false and not waiting', async () => {
|
||||
@@ -319,7 +380,7 @@ describe('usePhraseCycler', () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toBe('Waiting for user confirmation...');
|
||||
|
||||
// Go back to active cycling - should pick a phrase based on the logic (witty due to mock)
|
||||
rerender(<TestComponent isActive={true} isWaiting={false} />);
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { SHELL_FOCUS_HINT_DELAY_MS } from '../constants.js';
|
||||
import { INFORMATIVE_TIPS } from '../constants/tips.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../constants/wittyPhrases.js';
|
||||
import { useInactivityTimer } from './useInactivityTimer.js';
|
||||
|
||||
export const PHRASE_CHANGE_INTERVAL_MS = 15000;
|
||||
export const INTERACTIVE_SHELL_WAITING_PHRASE =
|
||||
@@ -16,14 +18,15 @@ export const INTERACTIVE_SHELL_WAITING_PHRASE =
|
||||
* Custom hook to manage cycling through loading phrases.
|
||||
* @param isActive Whether the phrase cycling should be active.
|
||||
* @param isWaiting Whether to show a specific waiting phrase.
|
||||
* @param shouldShowFocusHint Whether to show the shell focus hint.
|
||||
* @param isInteractiveShellWaiting Whether an interactive shell is waiting for input but not focused.
|
||||
* @param customPhrases Optional list of custom phrases to use.
|
||||
* @returns The current loading phrase.
|
||||
*/
|
||||
export const usePhraseCycler = (
|
||||
isActive: boolean,
|
||||
isWaiting: boolean,
|
||||
shouldShowFocusHint: boolean,
|
||||
isInteractiveShellWaiting: boolean,
|
||||
lastOutputTime: number = 0,
|
||||
customPhrases?: string[],
|
||||
) => {
|
||||
const loadingPhrases =
|
||||
@@ -34,7 +37,11 @@ export const usePhraseCycler = (
|
||||
const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState(
|
||||
loadingPhrases[0],
|
||||
);
|
||||
|
||||
const showShellFocusHint = useInactivityTimer(
|
||||
isInteractiveShellWaiting,
|
||||
lastOutputTime,
|
||||
SHELL_FOCUS_HINT_DELAY_MS,
|
||||
);
|
||||
const phraseIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasShownFirstRequestTipRef = useRef(false);
|
||||
|
||||
@@ -45,7 +52,7 @@ export const usePhraseCycler = (
|
||||
phraseIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (shouldShowFocusHint) {
|
||||
if (isInteractiveShellWaiting && showShellFocusHint) {
|
||||
setCurrentLoadingPhrase(INTERACTIVE_SHELL_WAITING_PHRASE);
|
||||
return;
|
||||
}
|
||||
@@ -95,7 +102,14 @@ export const usePhraseCycler = (
|
||||
phraseIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isActive, isWaiting, shouldShowFocusHint, customPhrases, loadingPhrases]);
|
||||
}, [
|
||||
isActive,
|
||||
isWaiting,
|
||||
isInteractiveShellWaiting,
|
||||
customPhrases,
|
||||
loadingPhrases,
|
||||
showShellFocusHint,
|
||||
]);
|
||||
|
||||
return currentLoadingPhrase;
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useShellInactivityStatus } from './useShellInactivityStatus.js';
|
||||
import { useTurnActivityMonitor } from './useTurnActivityMonitor.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
vi.mock('./useTurnActivityMonitor.js', () => ({
|
||||
useTurnActivityMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useShellInactivityStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(useTurnActivityMonitor).mockReturnValue({
|
||||
operationStartTime: 1000,
|
||||
isRedirectionActive: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
activePtyId: 'pty-1',
|
||||
lastOutputTime: 1001,
|
||||
streamingState: StreamingState.Responding,
|
||||
pendingToolCalls: [],
|
||||
embeddedShellFocused: false,
|
||||
isInteractiveShellEnabled: true,
|
||||
};
|
||||
|
||||
it('should show action_required status after 30s when output has been produced', async () => {
|
||||
const { result } = renderHook(() => useShellInactivityStatus(defaultProps));
|
||||
|
||||
expect(result.current.inactivityStatus).toBe('none');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
});
|
||||
expect(result.current.inactivityStatus).toBe('action_required');
|
||||
});
|
||||
|
||||
it('should show silent_working status after 60s when no output has been produced (silent)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useShellInactivityStatus({ ...defaultProps, lastOutputTime: 500 }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
});
|
||||
expect(result.current.inactivityStatus).toBe('none');
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
});
|
||||
expect(result.current.inactivityStatus).toBe('silent_working');
|
||||
});
|
||||
|
||||
it('should show silent_working status after 2 mins for redirected commands', async () => {
|
||||
vi.mocked(useTurnActivityMonitor).mockReturnValue({
|
||||
operationStartTime: 1000,
|
||||
isRedirectionActive: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useShellInactivityStatus(defaultProps));
|
||||
|
||||
// Should NOT show action_required even after 60s
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
});
|
||||
expect(result.current.inactivityStatus).toBe('none');
|
||||
|
||||
// Should show silent_working after 2 mins (120000ms)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
});
|
||||
expect(result.current.inactivityStatus).toBe('silent_working');
|
||||
});
|
||||
|
||||
it('should suppress focus hint when redirected', async () => {
|
||||
vi.mocked(useTurnActivityMonitor).mockReturnValue({
|
||||
operationStartTime: 1000,
|
||||
isRedirectionActive: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useShellInactivityStatus(defaultProps));
|
||||
|
||||
// Even after delay, focus hint should be suppressed
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
});
|
||||
expect(result.current.shouldShowFocusHint).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useInactivityTimer } from './useInactivityTimer.js';
|
||||
import { useTurnActivityMonitor } from './useTurnActivityMonitor.js';
|
||||
import {
|
||||
SHELL_FOCUS_HINT_DELAY_MS,
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
SHELL_SILENT_WORKING_TITLE_DELAY_MS,
|
||||
} from '../constants.js';
|
||||
import type { StreamingState } from '../types.js';
|
||||
import { type TrackedToolCall } from './useReactToolScheduler.js';
|
||||
|
||||
interface ShellInactivityStatusProps {
|
||||
activePtyId: number | string | null | undefined;
|
||||
lastOutputTime: number;
|
||||
streamingState: StreamingState;
|
||||
pendingToolCalls: TrackedToolCall[];
|
||||
embeddedShellFocused: boolean;
|
||||
isInteractiveShellEnabled: boolean;
|
||||
}
|
||||
|
||||
export type InactivityStatus = 'none' | 'action_required' | 'silent_working';
|
||||
|
||||
export interface ShellInactivityStatus {
|
||||
shouldShowFocusHint: boolean;
|
||||
inactivityStatus: InactivityStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidated hook to manage all shell-related inactivity states.
|
||||
* Centralizes the timing heuristics and redirection suppression logic.
|
||||
*/
|
||||
export const useShellInactivityStatus = ({
|
||||
activePtyId,
|
||||
lastOutputTime,
|
||||
streamingState,
|
||||
pendingToolCalls,
|
||||
embeddedShellFocused,
|
||||
isInteractiveShellEnabled,
|
||||
}: ShellInactivityStatusProps): ShellInactivityStatus => {
|
||||
const { operationStartTime, isRedirectionActive } = useTurnActivityMonitor(
|
||||
streamingState,
|
||||
activePtyId,
|
||||
pendingToolCalls,
|
||||
);
|
||||
|
||||
const isAwaitingFocus =
|
||||
!!activePtyId && !embeddedShellFocused && isInteractiveShellEnabled;
|
||||
|
||||
// Derive whether output was produced by comparing the last output time to when the operation started.
|
||||
const hasProducedOutput = lastOutputTime > operationStartTime;
|
||||
|
||||
// 1. Focus Hint (The "press tab to focus" message in the loading indicator)
|
||||
// Logic: 5s if output has been produced, 20s if silent. Suppressed if redirected.
|
||||
const shouldShowFocusHint = useInactivityTimer(
|
||||
isAwaitingFocus && !isRedirectionActive,
|
||||
lastOutputTime,
|
||||
hasProducedOutput
|
||||
? SHELL_FOCUS_HINT_DELAY_MS
|
||||
: SHELL_FOCUS_HINT_DELAY_MS * 4,
|
||||
);
|
||||
|
||||
// 2. Action Required Status (The ✋ icon in the terminal window title)
|
||||
// Logic: Only if output has been produced (likely a prompt).
|
||||
// Triggered after 30s of silence, but SUPPRESSED if redirection is active.
|
||||
const shouldShowActionRequiredTitle = useInactivityTimer(
|
||||
isAwaitingFocus && !isRedirectionActive && hasProducedOutput,
|
||||
lastOutputTime,
|
||||
SHELL_ACTION_REQUIRED_TITLE_DELAY_MS,
|
||||
);
|
||||
|
||||
// 3. Silent Working Status (The ⏲ icon in the terminal window title)
|
||||
// Logic: If redirected OR if no output has been produced yet (e.g. sleep 600).
|
||||
// Triggered after 2 mins for redirected, or 60s for non-redirected silent commands.
|
||||
const shouldShowSilentWorkingTitle = useInactivityTimer(
|
||||
isAwaitingFocus && (isRedirectionActive || !hasProducedOutput),
|
||||
lastOutputTime,
|
||||
isRedirectionActive
|
||||
? SHELL_SILENT_WORKING_TITLE_DELAY_MS
|
||||
: SHELL_ACTION_REQUIRED_TITLE_DELAY_MS * 2,
|
||||
);
|
||||
|
||||
let inactivityStatus: InactivityStatus = 'none';
|
||||
if (shouldShowActionRequiredTitle) {
|
||||
inactivityStatus = 'action_required';
|
||||
} else if (shouldShowSilentWorkingTitle) {
|
||||
inactivityStatus = 'silent_working';
|
||||
}
|
||||
|
||||
return {
|
||||
shouldShowFocusHint,
|
||||
inactivityStatus,
|
||||
};
|
||||
};
|
||||
@@ -316,6 +316,7 @@ function useCommandSuggestions(
|
||||
value: cmd.name,
|
||||
description: cmd.description,
|
||||
commandKind: cmd.kind,
|
||||
extensionName: cmd.extensionName,
|
||||
}));
|
||||
|
||||
setSuggestions(finalSuggestions);
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useTurnActivityMonitor } from './useTurnActivityMonitor.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { hasRedirection } from '@google/gemini-cli-core';
|
||||
import { type TrackedToolCall } from './useReactToolScheduler.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
hasRedirection: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useTurnActivityMonitor', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
vi.mocked(hasRedirection).mockImplementation(
|
||||
(query: string) => query.includes('>') || query.includes('>>'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should set operationStartTime when entering Responding state', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ state }) => useTurnActivityMonitor(state, null, []),
|
||||
{
|
||||
initialProps: { state: StreamingState.Idle },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.operationStartTime).toBe(0);
|
||||
|
||||
rerender({ state: StreamingState.Responding });
|
||||
expect(result.current.operationStartTime).toBe(1000);
|
||||
});
|
||||
|
||||
it('should reset operationStartTime when PTY ID changes while responding', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ state, ptyId }) => useTurnActivityMonitor(state, ptyId, []),
|
||||
{
|
||||
initialProps: {
|
||||
state: StreamingState.Responding,
|
||||
ptyId: 'pty-1' as string | null,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.operationStartTime).toBe(1000);
|
||||
|
||||
vi.setSystemTime(2000);
|
||||
rerender({ state: StreamingState.Responding, ptyId: 'pty-2' });
|
||||
expect(result.current.operationStartTime).toBe(2000);
|
||||
});
|
||||
|
||||
it('should detect redirection from tool calls', () => {
|
||||
// Force mock implementation to ensure it's active
|
||||
vi.mocked(hasRedirection).mockImplementation((q: string) =>
|
||||
q.includes('>'),
|
||||
);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ state, pendingToolCalls }) =>
|
||||
useTurnActivityMonitor(state, null, pendingToolCalls),
|
||||
{
|
||||
initialProps: {
|
||||
state: StreamingState.Responding,
|
||||
pendingToolCalls: [] as TrackedToolCall[],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.isRedirectionActive).toBe(false);
|
||||
|
||||
// Test non-redirected tool call
|
||||
rerender({
|
||||
state: StreamingState.Responding,
|
||||
pendingToolCalls: [
|
||||
{
|
||||
request: {
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'ls -la' },
|
||||
},
|
||||
status: 'executing',
|
||||
} as unknown as TrackedToolCall,
|
||||
],
|
||||
});
|
||||
expect(result.current.isRedirectionActive).toBe(false);
|
||||
|
||||
// Test tool call redirection
|
||||
rerender({
|
||||
state: StreamingState.Responding,
|
||||
pendingToolCalls: [
|
||||
{
|
||||
request: {
|
||||
name: 'run_shell_command',
|
||||
args: { command: 'ls > tool_out.txt' },
|
||||
},
|
||||
status: 'executing',
|
||||
} as unknown as TrackedToolCall,
|
||||
],
|
||||
});
|
||||
expect(result.current.isRedirectionActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should reset everything when idle', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ state }) => useTurnActivityMonitor(state, 'pty-1', []),
|
||||
{
|
||||
initialProps: { state: StreamingState.Responding },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.current.operationStartTime).toBe(1000);
|
||||
|
||||
rerender({ state: StreamingState.Idle });
|
||||
expect(result.current.operationStartTime).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { hasRedirection } from '@google/gemini-cli-core';
|
||||
import { type TrackedToolCall } from './useReactToolScheduler.js';
|
||||
|
||||
export interface TurnActivityStatus {
|
||||
operationStartTime: number;
|
||||
isRedirectionActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitors the activity of a Gemini turn to detect when a new operation starts
|
||||
* and whether it involves shell redirections that should suppress inactivity prompts.
|
||||
*/
|
||||
export const useTurnActivityMonitor = (
|
||||
streamingState: StreamingState,
|
||||
activePtyId: number | string | null | undefined,
|
||||
pendingToolCalls: TrackedToolCall[] = [],
|
||||
): TurnActivityStatus => {
|
||||
const [operationStartTime, setOperationStartTime] = useState(0);
|
||||
|
||||
// Reset operation start time whenever a new operation begins.
|
||||
// We consider an operation to have started when we enter Responding state,
|
||||
// OR when the active PTY changes (meaning a new command started within the turn).
|
||||
const prevPtyIdRef = useRef<number | string | null | undefined>(undefined);
|
||||
const prevStreamingStateRef = useRef<StreamingState | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const isNowResponding = streamingState === StreamingState.Responding;
|
||||
const wasResponding =
|
||||
prevStreamingStateRef.current === StreamingState.Responding;
|
||||
const ptyChanged = activePtyId !== prevPtyIdRef.current;
|
||||
|
||||
if (isNowResponding && (!wasResponding || ptyChanged)) {
|
||||
setOperationStartTime(Date.now());
|
||||
} else if (!isNowResponding && wasResponding) {
|
||||
setOperationStartTime(0);
|
||||
}
|
||||
|
||||
prevPtyIdRef.current = activePtyId;
|
||||
prevStreamingStateRef.current = streamingState;
|
||||
}, [streamingState, activePtyId]);
|
||||
|
||||
// Detect redirection in the current query or tool calls.
|
||||
// We derive this directly during render to ensure it's accurate from the first frame.
|
||||
const isRedirectionActive = useMemo(
|
||||
() =>
|
||||
// Check active tool calls for run_shell_command
|
||||
pendingToolCalls.some((tc) => {
|
||||
if (tc.request.name !== 'run_shell_command') return false;
|
||||
|
||||
const command =
|
||||
(tc.request.args as { command?: string })?.command || '';
|
||||
return hasRedirection(command);
|
||||
}),
|
||||
[pendingToolCalls],
|
||||
);
|
||||
|
||||
return {
|
||||
operationStartTime,
|
||||
isRedirectionActive,
|
||||
};
|
||||
};
|
||||
@@ -5,10 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
computeTerminalTitle,
|
||||
type TerminalTitleOptions,
|
||||
} from './windowTitle.js';
|
||||
import { computeTerminalTitle } from './windowTitle.js';
|
||||
import { StreamingState } from '../ui/types.js';
|
||||
|
||||
describe('computeTerminalTitle', () => {
|
||||
@@ -22,11 +19,10 @@ describe('computeTerminalTitle', () => {
|
||||
args: {
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: false,
|
||||
useDynamicTitle: true,
|
||||
} as TerminalTitleOptions,
|
||||
},
|
||||
expected: '◇ Ready (my-project)',
|
||||
},
|
||||
{
|
||||
@@ -34,11 +30,10 @@ describe('computeTerminalTitle', () => {
|
||||
args: {
|
||||
streamingState: StreamingState.Responding,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: false,
|
||||
} as TerminalTitleOptions,
|
||||
},
|
||||
expected: 'Gemini CLI (my-project)'.padEnd(80, ' '),
|
||||
exact: true,
|
||||
},
|
||||
@@ -49,11 +44,10 @@ describe('computeTerminalTitle', () => {
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: 'Reading files',
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: false,
|
||||
useDynamicTitle: true,
|
||||
} as TerminalTitleOptions,
|
||||
},
|
||||
expected: '✦ Working… (my-project)',
|
||||
},
|
||||
{
|
||||
@@ -63,11 +57,10 @@ describe('computeTerminalTitle', () => {
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: 'Short thought',
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: true,
|
||||
} as TerminalTitleOptions,
|
||||
},
|
||||
expected: '✦ Short thought (my-project)',
|
||||
},
|
||||
{
|
||||
@@ -77,11 +70,10 @@ describe('computeTerminalTitle', () => {
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: undefined,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: true,
|
||||
} as TerminalTitleOptions,
|
||||
},
|
||||
expected: '✦ Working… (my-project)'.padEnd(80, ' '),
|
||||
exact: true,
|
||||
},
|
||||
@@ -90,25 +82,12 @@ describe('computeTerminalTitle', () => {
|
||||
args: {
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: true,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: false,
|
||||
useDynamicTitle: true,
|
||||
} as TerminalTitleOptions,
|
||||
},
|
||||
expected: '✋ Action Required (my-project)',
|
||||
},
|
||||
{
|
||||
description: 'silent working state',
|
||||
args: {
|
||||
streamingState: StreamingState.Responding,
|
||||
isConfirming: false,
|
||||
isSilentWorking: true,
|
||||
folderName: 'my-project',
|
||||
showThoughts: false,
|
||||
useDynamicTitle: true,
|
||||
} as TerminalTitleOptions,
|
||||
expected: '⏲ Working… (my-project)',
|
||||
},
|
||||
])('should return $description', ({ args, expected, exact }) => {
|
||||
const title = computeTerminalTitle(args);
|
||||
if (exact) {
|
||||
@@ -125,7 +104,6 @@ describe('computeTerminalTitle', () => {
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: longThought,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: true,
|
||||
@@ -142,7 +120,6 @@ describe('computeTerminalTitle', () => {
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: longThought,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: true,
|
||||
@@ -158,7 +135,6 @@ describe('computeTerminalTitle', () => {
|
||||
streamingState: StreamingState.Responding,
|
||||
thoughtSubject: 'BadTitle\x00 With\x07Control\x1BChars',
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: true,
|
||||
useDynamicTitle: true,
|
||||
@@ -177,7 +153,6 @@ describe('computeTerminalTitle', () => {
|
||||
const title = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: 'my-project',
|
||||
showThoughts: false,
|
||||
useDynamicTitle: true,
|
||||
@@ -210,7 +185,6 @@ describe('computeTerminalTitle', () => {
|
||||
const title = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName,
|
||||
showThoughts: false,
|
||||
useDynamicTitle: true,
|
||||
@@ -227,7 +201,6 @@ describe('computeTerminalTitle', () => {
|
||||
const title = computeTerminalTitle({
|
||||
streamingState: StreamingState.Responding,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: longFolderName,
|
||||
showThoughts: true,
|
||||
useDynamicTitle: false,
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface TerminalTitleOptions {
|
||||
streamingState: StreamingState;
|
||||
thoughtSubject?: string;
|
||||
isConfirming: boolean;
|
||||
isSilentWorking: boolean;
|
||||
folderName: string;
|
||||
showThoughts: boolean;
|
||||
useDynamicTitle: boolean;
|
||||
@@ -33,7 +32,6 @@ export function computeTerminalTitle({
|
||||
streamingState,
|
||||
thoughtSubject,
|
||||
isConfirming,
|
||||
isSilentWorking,
|
||||
folderName,
|
||||
showThoughts,
|
||||
useDynamicTitle,
|
||||
@@ -64,12 +62,6 @@ export function computeTerminalTitle({
|
||||
const maxContextLen = MAX_LEN - base.length - 3;
|
||||
const context = truncate(displayContext, maxContextLen);
|
||||
title = `${base}${getSuffix(context)}`;
|
||||
} else if (isSilentWorking) {
|
||||
const base = '⏲ Working…';
|
||||
// Max context length is 80 - base.length - 3 (for ' (' and ')')
|
||||
const maxContextLen = MAX_LEN - base.length - 3;
|
||||
const context = truncate(displayContext, maxContextLen);
|
||||
title = `${base}${getSuffix(context)}`;
|
||||
} else if (streamingState === StreamingState.Idle) {
|
||||
const base = '◇ Ready';
|
||||
// Max context length is 80 - base.length - 3 (for ' (' and ')')
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export interface AcknowledgedAgentsMap {
|
||||
// Project Path -> Agent Name -> Agent Hash
|
||||
[projectPath: string]: {
|
||||
[agentName: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class AcknowledgedAgentsService {
|
||||
private static instance: AcknowledgedAgentsService;
|
||||
private acknowledgedAgents: AcknowledgedAgentsMap = {};
|
||||
private loaded = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): AcknowledgedAgentsService {
|
||||
if (!AcknowledgedAgentsService.instance) {
|
||||
AcknowledgedAgentsService.instance = new AcknowledgedAgentsService();
|
||||
}
|
||||
return AcknowledgedAgentsService.instance;
|
||||
}
|
||||
|
||||
static resetInstanceForTesting(): void {
|
||||
// @ts-expect-error -- Resetting private static instance for testing purposes
|
||||
AcknowledgedAgentsService.instance = undefined;
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (this.loaded) return;
|
||||
|
||||
const filePath = Storage.getAcknowledgedAgentsPath();
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
this.acknowledgedAgents = JSON.parse(content);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to load acknowledged agents:', error);
|
||||
// Fallback to empty
|
||||
this.acknowledgedAgents = {};
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const filePath = Storage.getAcknowledgedAgentsPath();
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(this.acknowledgedAgents, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to save acknowledged agents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
isAcknowledged(
|
||||
projectPath: string,
|
||||
agentName: string,
|
||||
hash: string,
|
||||
): boolean {
|
||||
this.load();
|
||||
const projectAgents = this.acknowledgedAgents[projectPath];
|
||||
if (!projectAgents) return false;
|
||||
return projectAgents[agentName] === hash;
|
||||
}
|
||||
|
||||
acknowledge(projectPath: string, agentName: string, hash: string): void {
|
||||
this.load();
|
||||
if (!this.acknowledgedAgents[projectPath]) {
|
||||
this.acknowledgedAgents[projectPath] = {};
|
||||
}
|
||||
this.acknowledgedAgents[projectPath][agentName] = hash;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
@@ -253,15 +253,11 @@ Body`);
|
||||
maxTimeMinutes: 5,
|
||||
},
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The task for the agent.',
|
||||
},
|
||||
inputs: {
|
||||
query: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -313,15 +309,12 @@ Body`);
|
||||
displayName: undefined,
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The task for the agent.',
|
||||
},
|
||||
inputs: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The task for the agent.',
|
||||
required: false,
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import yaml from 'js-yaml';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import { type Dirent } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import type { AgentDefinition } from './types.js';
|
||||
import {
|
||||
@@ -242,24 +241,18 @@ export async function parseAgentMarkdown(
|
||||
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
||||
*
|
||||
* @param markdown The parsed Markdown/Frontmatter definition.
|
||||
* @param metadata Optional metadata including hash and file path.
|
||||
* @returns The internal AgentDefinition.
|
||||
*/
|
||||
export function markdownToAgentDefinition(
|
||||
markdown: FrontmatterAgentDefinition,
|
||||
metadata?: { hash?: string; filePath?: string },
|
||||
): AgentDefinition {
|
||||
const inputConfig = {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The task for the agent.',
|
||||
},
|
||||
inputs: {
|
||||
query: {
|
||||
type: 'string' as const,
|
||||
description: 'The task for the agent.',
|
||||
required: false,
|
||||
},
|
||||
// query is not required because it defaults to "Get Started!" if not provided
|
||||
required: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -271,7 +264,6 @@ export function markdownToAgentDefinition(
|
||||
displayName: markdown.display_name,
|
||||
agentCardUrl: markdown.agent_card_url,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -304,7 +296,6 @@ export function markdownToAgentDefinition(
|
||||
}
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,11 +342,9 @@ export async function loadAgentsFromDirectory(
|
||||
for (const entry of files) {
|
||||
const filePath = path.join(dir, entry.name);
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
||||
const agentDefs = await parseAgentMarkdown(filePath);
|
||||
for (const def of agentDefs) {
|
||||
const agent = markdownToAgentDefinition(def, { hash, filePath });
|
||||
const agent = markdownToAgentDefinition(def);
|
||||
result.agents.push(agent);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -26,10 +26,8 @@ describe('CliHelpAgent', () => {
|
||||
});
|
||||
|
||||
it('should have correctly configured inputs and outputs', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const inputSchema = localAgent.inputConfig.inputSchema as any;
|
||||
expect(inputSchema.properties['question']).toBeDefined();
|
||||
expect(inputSchema.required).toContain('question');
|
||||
expect(localAgent.inputConfig.inputs['question']).toBeDefined();
|
||||
expect(localAgent.inputConfig.inputs['question'].required).toBe(true);
|
||||
|
||||
expect(localAgent.outputConfig?.outputName).toBe('report');
|
||||
expect(localAgent.outputConfig?.description).toBeDefined();
|
||||
|
||||
@@ -32,15 +32,12 @@ export const CliHelpAgent = (
|
||||
description:
|
||||
'Specialized in answering questions about how users use you, (Gemini CLI): features, documentation, and current runtime configuration.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: {
|
||||
type: 'string',
|
||||
description: 'The specific question about Gemini CLI.',
|
||||
},
|
||||
inputs: {
|
||||
question: {
|
||||
description: 'The specific question about Gemini CLI.',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
required: ['question'],
|
||||
},
|
||||
},
|
||||
outputConfig: {
|
||||
|
||||
@@ -21,11 +21,9 @@ describe('CodebaseInvestigatorAgent', () => {
|
||||
'Codebase Investigator Agent',
|
||||
);
|
||||
expect(CodebaseInvestigatorAgent.description).toBeDefined();
|
||||
const inputSchema =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
CodebaseInvestigatorAgent.inputConfig.inputSchema as any;
|
||||
expect(inputSchema.properties['objective']).toBeDefined();
|
||||
expect(inputSchema.required).toContain('objective');
|
||||
expect(
|
||||
CodebaseInvestigatorAgent.inputConfig.inputs['objective'].required,
|
||||
).toBe(true);
|
||||
expect(CodebaseInvestigatorAgent.outputConfig?.outputName).toBe('report');
|
||||
expect(CodebaseInvestigatorAgent.modelConfig?.model).toBe(
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
|
||||
@@ -51,16 +51,13 @@ export const CodebaseInvestigatorAgent: LocalAgentDefinition<
|
||||
Invoke this tool for tasks like vague requests, bug root-cause analysis, system refactoring, comprehensive feature implementation or to answer questions about the codebase that require investigation.
|
||||
It returns a structured report with key file paths, symbols, and actionable architectural insights.`,
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
objective: {
|
||||
type: 'string',
|
||||
description: `A comprehensive and detailed description of the user's ultimate goal.
|
||||
inputs: {
|
||||
objective: {
|
||||
description: `A comprehensive and detailed description of the user's ultimate goal.
|
||||
You must include original user's objective as well as questions and any extra context and questions you may have.`,
|
||||
},
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
required: ['objective'],
|
||||
},
|
||||
},
|
||||
outputConfig: {
|
||||
|
||||
@@ -61,13 +61,9 @@ describe('DelegateToAgentTool', () => {
|
||||
},
|
||||
},
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
arg1: { type: 'string', description: 'Argument 1' },
|
||||
arg2: { type: 'number', description: 'Argument 2' },
|
||||
},
|
||||
required: ['arg1'],
|
||||
inputs: {
|
||||
arg1: { type: 'string', description: 'Argument 1', required: true },
|
||||
arg2: { type: 'number', description: 'Argument 2', required: false },
|
||||
},
|
||||
},
|
||||
runConfig: { maxTurns: 1, maxTimeMinutes: 1 },
|
||||
@@ -80,12 +76,8 @@ describe('DelegateToAgentTool', () => {
|
||||
description: 'A remote agent',
|
||||
agentCardUrl: 'https://example.com/agent.json',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Query' },
|
||||
},
|
||||
required: ['query'],
|
||||
inputs: {
|
||||
query: { type: 'string', description: 'Query', required: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -161,7 +153,7 @@ describe('DelegateToAgentTool', () => {
|
||||
await expect(() =>
|
||||
invocation.execute(new AbortController().signal),
|
||||
).rejects.toThrow(
|
||||
`Invalid arguments for agent 'test_agent': params must have required property 'arg1'. Input schema: ${JSON.stringify(mockAgentDef.inputConfig.inputSchema)}.`,
|
||||
"arg1: Required. Expected inputs: 'arg1' (required string), 'arg2' (optional number).",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -174,7 +166,7 @@ describe('DelegateToAgentTool', () => {
|
||||
await expect(() =>
|
||||
invocation.execute(new AbortController().signal),
|
||||
).rejects.toThrow(
|
||||
`Invalid arguments for agent 'test_agent': params/arg1 must be string. Input schema: ${JSON.stringify(mockAgentDef.inputConfig.inputSchema)}.`,
|
||||
"arg1: Expected string, received number. Expected inputs: 'arg1' (required string), 'arg2' (optional number).",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,15 +187,12 @@ describe('DelegateToAgentTool', () => {
|
||||
...mockAgentDef,
|
||||
name: 'invalid_agent',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agent_name: {
|
||||
type: 'string',
|
||||
description: 'Conflict',
|
||||
},
|
||||
inputs: {
|
||||
agent_name: {
|
||||
type: 'string',
|
||||
description: 'Conflict',
|
||||
required: true,
|
||||
},
|
||||
required: ['agent_name'],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -216,37 +205,6 @@ describe('DelegateToAgentTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow a remote agent missing a "query" input (will default at runtime)', () => {
|
||||
const invalidRemoteAgentDef: AgentDefinition = {
|
||||
kind: 'remote',
|
||||
name: 'invalid_remote',
|
||||
description: 'Conflict',
|
||||
agentCardUrl: 'https://example.com/agent.json',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
not_query: {
|
||||
type: 'string',
|
||||
description: 'Not a query',
|
||||
},
|
||||
},
|
||||
required: ['not_query'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(registry as any).agents.set(
|
||||
invalidRemoteAgentDef.name,
|
||||
invalidRemoteAgentDef,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => new DelegateToAgentTool(registry, config, messageBus),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should execute local agents silently without requesting confirmation', async () => {
|
||||
const invocation = tool.build({
|
||||
agent_name: 'test_agent',
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
Kind,
|
||||
@@ -19,9 +21,6 @@ import type { Config } from '../config/config.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { AgentDefinition, AgentInputs } from './types.js';
|
||||
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
|
||||
import { SchemaValidator } from '../utils/schemaValidator.js';
|
||||
import { type AnySchema } from 'ajv';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export type DelegateParams = { agent_name: string } & Record<string, unknown>;
|
||||
|
||||
@@ -36,76 +35,82 @@ export class DelegateToAgentTool extends BaseDeclarativeTool<
|
||||
) {
|
||||
const definitions = registry.getAllDefinitions();
|
||||
|
||||
let toolSchema: AnySchema;
|
||||
let schema: z.ZodTypeAny;
|
||||
|
||||
if (definitions.length === 0) {
|
||||
// Fallback if no agents are registered (mostly for testing/safety)
|
||||
toolSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agent_name: {
|
||||
type: 'string',
|
||||
description: 'No agents are currently available.',
|
||||
},
|
||||
},
|
||||
required: ['agent_name'],
|
||||
};
|
||||
schema = z.object({
|
||||
agent_name: z.string().describe('No agents are currently available.'),
|
||||
});
|
||||
} else {
|
||||
const agentSchemas = definitions.map((def) => {
|
||||
const schemaError = SchemaValidator.validateSchema(
|
||||
def.inputConfig.inputSchema,
|
||||
);
|
||||
if (schemaError) {
|
||||
throw new Error(`Invalid schema for ${def.name}: ${schemaError}`);
|
||||
}
|
||||
const inputShape: Record<string, z.ZodTypeAny> = {
|
||||
agent_name: z.literal(def.name).describe(def.description),
|
||||
};
|
||||
|
||||
const inputSchema = def.inputConfig.inputSchema;
|
||||
if (typeof inputSchema !== 'object' || inputSchema === null) {
|
||||
throw new Error(`Agent '${def.name}' must provide an object schema.`);
|
||||
}
|
||||
|
||||
const schemaObj = inputSchema as Record<string, unknown>;
|
||||
const properties = schemaObj['properties'] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (properties && 'agent_name' in properties) {
|
||||
throw new Error(
|
||||
`Agent '${def.name}' cannot have an input parameter named 'agent_name' as it is a reserved parameter for delegation.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (def.kind === 'remote') {
|
||||
if (!properties || !properties['query']) {
|
||||
debugLogger.log(
|
||||
'INFO',
|
||||
`Remote agent '${def.name}' does not define a 'query' property in its inputSchema. It will default to 'Get Started!' during invocation.`,
|
||||
for (const [key, inputDef] of Object.entries(def.inputConfig.inputs)) {
|
||||
if (key === 'agent_name') {
|
||||
throw new Error(
|
||||
`Agent '${def.name}' cannot have an input parameter named 'agent_name' as it is a reserved parameter for delegation.`,
|
||||
);
|
||||
}
|
||||
|
||||
let validator: z.ZodTypeAny;
|
||||
|
||||
// Map input types to Zod
|
||||
switch (inputDef.type) {
|
||||
case 'string':
|
||||
validator = z.string();
|
||||
break;
|
||||
case 'number':
|
||||
validator = z.number();
|
||||
break;
|
||||
case 'boolean':
|
||||
validator = z.boolean();
|
||||
break;
|
||||
case 'integer':
|
||||
validator = z.number().int();
|
||||
break;
|
||||
case 'string[]':
|
||||
validator = z.array(z.string());
|
||||
break;
|
||||
case 'number[]':
|
||||
validator = z.array(z.number());
|
||||
break;
|
||||
default: {
|
||||
// This provides compile-time exhaustiveness checking.
|
||||
const _exhaustiveCheck: never = inputDef.type;
|
||||
void _exhaustiveCheck;
|
||||
throw new Error(`Unhandled agent input type: '${inputDef.type}'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inputDef.required) {
|
||||
validator = validator.optional();
|
||||
}
|
||||
|
||||
inputShape[key] = validator.describe(inputDef.description);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agent_name: {
|
||||
const: def.name,
|
||||
description: def.description,
|
||||
},
|
||||
...(properties || {}),
|
||||
},
|
||||
required: [
|
||||
'agent_name',
|
||||
...((schemaObj['required'] as string[]) || []),
|
||||
],
|
||||
} as AnySchema;
|
||||
// Cast required because Zod can't infer the discriminator from dynamic keys
|
||||
return z.object(
|
||||
inputShape,
|
||||
) as z.ZodDiscriminatedUnionOption<'agent_name'>;
|
||||
});
|
||||
|
||||
// Create the anyOf schema
|
||||
// Create the discriminated union
|
||||
// z.discriminatedUnion requires at least 2 options, so we handle the single agent case
|
||||
if (agentSchemas.length === 1) {
|
||||
toolSchema = agentSchemas[0];
|
||||
schema = agentSchemas[0];
|
||||
} else {
|
||||
toolSchema = {
|
||||
anyOf: agentSchemas,
|
||||
};
|
||||
schema = z.discriminatedUnion(
|
||||
'agent_name',
|
||||
agentSchemas as [
|
||||
z.ZodDiscriminatedUnionOption<'agent_name'>,
|
||||
z.ZodDiscriminatedUnionOption<'agent_name'>,
|
||||
...Array<z.ZodDiscriminatedUnionOption<'agent_name'>>,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +119,7 @@ export class DelegateToAgentTool extends BaseDeclarativeTool<
|
||||
'Delegate to Agent',
|
||||
registry.getToolDescription(),
|
||||
Kind.Think,
|
||||
toolSchema,
|
||||
zodToJsonSchema(schema),
|
||||
messageBus,
|
||||
/* isOutputMarkdown */ true,
|
||||
/* canUpdateOutput */ true,
|
||||
@@ -205,16 +210,65 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
|
||||
const { agent_name: _agent_name, ...agentArgs } = this.params;
|
||||
|
||||
// Validate specific agent arguments here using SchemaValidator to generate helpful error messages.
|
||||
const validationError = SchemaValidator.validate(
|
||||
definition.inputConfig.inputSchema,
|
||||
agentArgs,
|
||||
);
|
||||
// Validate specific agent arguments here using Zod to generate helpful error messages.
|
||||
const inputShape: Record<string, z.ZodTypeAny> = {};
|
||||
for (const [key, inputDef] of Object.entries(
|
||||
definition.inputConfig.inputs,
|
||||
)) {
|
||||
let validator: z.ZodTypeAny;
|
||||
|
||||
if (validationError) {
|
||||
throw new Error(
|
||||
`Invalid arguments for agent '${definition.name}': ${validationError}. Input schema: ${JSON.stringify(definition.inputConfig.inputSchema)}.`,
|
||||
);
|
||||
switch (inputDef.type) {
|
||||
case 'string':
|
||||
validator = z.string();
|
||||
break;
|
||||
case 'number':
|
||||
validator = z.number();
|
||||
break;
|
||||
case 'boolean':
|
||||
validator = z.boolean();
|
||||
break;
|
||||
case 'integer':
|
||||
validator = z.number().int();
|
||||
break;
|
||||
case 'string[]':
|
||||
validator = z.array(z.string());
|
||||
break;
|
||||
case 'number[]':
|
||||
validator = z.array(z.number());
|
||||
break;
|
||||
default:
|
||||
validator = z.unknown();
|
||||
}
|
||||
|
||||
if (!inputDef.required) {
|
||||
validator = validator.optional();
|
||||
}
|
||||
|
||||
inputShape[key] = validator.describe(inputDef.description);
|
||||
}
|
||||
|
||||
const agentSchema = z.object(inputShape);
|
||||
|
||||
try {
|
||||
agentSchema.parse(agentArgs);
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
const errorMessages = e.errors.map(
|
||||
(err) => `${err.path.join('.')}: ${err.message}`,
|
||||
);
|
||||
|
||||
const expectedInputs = Object.entries(definition.inputConfig.inputs)
|
||||
.map(
|
||||
([key, input]) =>
|
||||
`'${key}' (${input.required ? 'required' : 'optional'} ${input.type})`,
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
throw new Error(
|
||||
`${errorMessages.join(', ')}. Expected inputs: ${expectedInputs}.`,
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const invocation = this.buildSubInvocation(
|
||||
|
||||
@@ -28,15 +28,12 @@ export const GeneralistAgent = (
|
||||
"A general-purpose AI agent with access to all tools. Use it for complex tasks that don't fit into other specialized agents.",
|
||||
experimental: true,
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
request: {
|
||||
type: 'string',
|
||||
description: 'The task or question for the generalist agent.',
|
||||
},
|
||||
inputs: {
|
||||
request: {
|
||||
description: 'The task or question for the generalist agent.',
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
required: ['request'],
|
||||
},
|
||||
},
|
||||
outputConfig: {
|
||||
|
||||
@@ -223,13 +223,7 @@ const createTestDefinition = <TOutput extends z.ZodTypeAny = z.ZodUnknown>(
|
||||
name: 'TestAgent',
|
||||
description: 'An agent for testing.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
goal: { type: 'string', description: 'goal' },
|
||||
},
|
||||
required: ['goal'],
|
||||
},
|
||||
inputs: { goal: { type: 'string', required: true, description: 'goal' } },
|
||||
},
|
||||
modelConfig: {
|
||||
model: 'gemini-test-model',
|
||||
@@ -417,12 +411,8 @@ describe('LocalAgentExecutor', () => {
|
||||
it('should log AgentFinish with error if run throws', async () => {
|
||||
const definition = createTestDefinition();
|
||||
// Make the definition invalid to cause an error during run
|
||||
definition.inputConfig.inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
goal: { type: 'string', description: 'goal' },
|
||||
},
|
||||
required: ['goal'],
|
||||
definition.inputConfig.inputs = {
|
||||
goal: { type: 'string', required: true, description: 'goal' },
|
||||
};
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
|
||||
@@ -38,7 +38,7 @@ import type {
|
||||
OutputObject,
|
||||
SubagentActivityEvent,
|
||||
} from './types.js';
|
||||
import { AgentTerminateMode, DEFAULT_QUERY_STRING } from './types.js';
|
||||
import { AgentTerminateMode } from './types.js';
|
||||
import { templateString } from './utils.js';
|
||||
import { DEFAULT_GEMINI_MODEL, isAutoModel } from '../config/models.js';
|
||||
import type { RoutingContext } from '../routing/routingStrategy.js';
|
||||
@@ -395,7 +395,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
chat = await this.createChatObject(augmentedInputs, tools);
|
||||
const query = this.definition.promptConfig.query
|
||||
? templateString(this.definition.promptConfig.query, augmentedInputs)
|
||||
: DEFAULT_QUERY_STRING;
|
||||
: 'Get Started!';
|
||||
let currentMessage: Content = { role: 'user', parts: [{ text: query }] };
|
||||
|
||||
while (true) {
|
||||
|
||||
@@ -28,13 +28,9 @@ const testDefinition: LocalAgentDefinition<z.ZodUnknown> = {
|
||||
name: 'MockAgent',
|
||||
description: 'A mock agent.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: { type: 'string', description: 'task' },
|
||||
priority: { type: 'number', description: 'prio' },
|
||||
},
|
||||
required: ['task'],
|
||||
inputs: {
|
||||
task: { type: 'string', required: true, description: 'task' },
|
||||
priority: { type: 'number', required: false, description: 'prio' },
|
||||
},
|
||||
},
|
||||
modelConfig: {
|
||||
|
||||
@@ -59,7 +59,7 @@ const MOCK_AGENT_V1: AgentDefinition = {
|
||||
kind: 'local',
|
||||
name: 'MockAgent',
|
||||
description: 'Mock Description V1',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
inputConfig: { inputs: {} },
|
||||
modelConfig: {
|
||||
model: 'test',
|
||||
generateContentConfig: {
|
||||
@@ -447,7 +447,7 @@ describe('AgentRegistry', () => {
|
||||
name: 'RemoteAgent',
|
||||
description: 'A remote agent',
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
inputConfig: { inputs: {} },
|
||||
};
|
||||
|
||||
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
|
||||
@@ -470,7 +470,7 @@ describe('AgentRegistry', () => {
|
||||
name: 'RemoteAgent',
|
||||
description: 'A remote agent',
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
inputConfig: { inputs: {} },
|
||||
};
|
||||
|
||||
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
|
||||
@@ -603,9 +603,7 @@ describe('AgentRegistry', () => {
|
||||
|
||||
expect(clearCacheSpy).toHaveBeenCalled();
|
||||
expect(registry.getDefinition('InitialAgent')).toBeUndefined();
|
||||
expect(registry.getDiscoveredDefinition('InitialAgent')).toBeUndefined();
|
||||
expect(registry.getDefinition('NewAgent')).toBeDefined();
|
||||
expect(registry.getDiscoveredDefinition('NewAgent')).toBeDefined();
|
||||
expect(emitSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -699,65 +697,6 @@ describe('AgentRegistry', () => {
|
||||
expect.arrayContaining([MOCK_AGENT_V1, ANOTHER_AGENT]),
|
||||
);
|
||||
});
|
||||
|
||||
it('getAllDiscoveredAgentNames should return all names including disabled ones', async () => {
|
||||
const configWithDisabled = makeMockedConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
DisabledAgent: { enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
const registryWithDisabled = new TestableAgentRegistry(
|
||||
configWithDisabled,
|
||||
);
|
||||
|
||||
const enabledAgent = { ...MOCK_AGENT_V1, name: 'EnabledAgent' };
|
||||
const disabledAgent = { ...MOCK_AGENT_V1, name: 'DisabledAgent' };
|
||||
|
||||
await registryWithDisabled.testRegisterAgent(enabledAgent);
|
||||
await registryWithDisabled.testRegisterAgent(disabledAgent);
|
||||
|
||||
const discoveredNames = registryWithDisabled.getAllDiscoveredAgentNames();
|
||||
expect(discoveredNames).toContain('EnabledAgent');
|
||||
expect(discoveredNames).toContain('DisabledAgent');
|
||||
expect(discoveredNames).toHaveLength(2);
|
||||
|
||||
const activeNames = registryWithDisabled.getAllAgentNames();
|
||||
expect(activeNames).toContain('EnabledAgent');
|
||||
expect(activeNames).not.toContain('DisabledAgent');
|
||||
expect(activeNames).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('getDiscoveredDefinition should return the definition for a disabled agent', async () => {
|
||||
const configWithDisabled = makeMockedConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
DisabledAgent: { enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
const registryWithDisabled = new TestableAgentRegistry(
|
||||
configWithDisabled,
|
||||
);
|
||||
|
||||
const disabledAgent = {
|
||||
...MOCK_AGENT_V1,
|
||||
name: 'DisabledAgent',
|
||||
description: 'I am disabled',
|
||||
};
|
||||
|
||||
await registryWithDisabled.testRegisterAgent(disabledAgent);
|
||||
|
||||
expect(
|
||||
registryWithDisabled.getDefinition('DisabledAgent'),
|
||||
).toBeUndefined();
|
||||
|
||||
const discovered =
|
||||
registryWithDisabled.getDiscoveredDefinition('DisabledAgent');
|
||||
expect(discovered).toBeDefined();
|
||||
expect(discovered?.description).toBe('I am disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('overrides', () => {
|
||||
@@ -791,7 +730,7 @@ describe('AgentRegistry', () => {
|
||||
name: 'RemoteAgent',
|
||||
description: 'A remote agent',
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
inputConfig: { inputs: {} },
|
||||
};
|
||||
|
||||
await registry.testRegisterAgent(remoteAgent);
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
*/
|
||||
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { CoreEvent, coreEvents } from '../utils/events.js';
|
||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
||||
import type { AgentOverride, Config } from '../config/config.js';
|
||||
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
|
||||
import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||
import { AcknowledgedAgentsService } from './acknowledgedAgents.js';
|
||||
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
|
||||
import { CliHelpAgent } from './cli-help-agent.js';
|
||||
import { GeneralistAgent } from './generalist-agent.js';
|
||||
@@ -46,8 +45,6 @@ export function getModelConfigAlias<TOutput extends z.ZodTypeAny>(
|
||||
export class AgentRegistry {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly agents = new Map<string, AgentDefinition<any>>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly allDefinitions = new Map<string, AgentDefinition<any>>();
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@@ -76,24 +73,10 @@ export class AgentRegistry {
|
||||
A2AClientManager.getInstance().clearCache();
|
||||
await this.config.reloadAgents();
|
||||
this.agents.clear();
|
||||
this.allDefinitions.clear();
|
||||
await this.loadAgents();
|
||||
coreEvents.emitAgentsRefreshed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledges and registers a previously unacknowledged agent.
|
||||
*/
|
||||
async acknowledgeAgent(agent: AgentDefinition): Promise<void> {
|
||||
const ackService = AcknowledgedAgentsService.getInstance();
|
||||
const projectRoot = this.config.getProjectRoot();
|
||||
if (agent.metadata?.hash) {
|
||||
ackService.acknowledge(projectRoot, agent.name, agent.metadata.hash);
|
||||
await this.registerAgent(agent);
|
||||
coreEvents.emitAgentsRefreshed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of resources and removes event listeners.
|
||||
*/
|
||||
@@ -102,8 +85,6 @@ export class AgentRegistry {
|
||||
}
|
||||
|
||||
private async loadAgents(): Promise<void> {
|
||||
this.agents.clear();
|
||||
this.allDefinitions.clear();
|
||||
this.loadBuiltInAgents();
|
||||
|
||||
if (!this.config.isAgentsEnabled()) {
|
||||
@@ -136,42 +117,8 @@ export class AgentRegistry {
|
||||
`Agent loading error: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ackService = AcknowledgedAgentsService.getInstance();
|
||||
const projectRoot = this.config.getProjectRoot();
|
||||
const unacknowledgedAgents: AgentDefinition[] = [];
|
||||
|
||||
const agentsToRegister = projectAgents.agents.filter((agent) => {
|
||||
// If it's a remote agent, we might not have a hash, or we handle it differently.
|
||||
// For now, assuming project agents are primarily local .md files with hashes.
|
||||
// If metadata or hash is missing, we default to "safe" (allow) or "unsafe" (block)?
|
||||
// Existing behavior was allow all. To be safe, if we can't identify it, maybe we should block?
|
||||
// But for backward compatibility with existing agents without hash (if any), maybe allow?
|
||||
// Our loader ensures hash is there.
|
||||
if (!agent.metadata?.hash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
ackService.isAcknowledged(
|
||||
projectRoot,
|
||||
agent.name,
|
||||
agent.metadata.hash,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
unacknowledgedAgents.push(agent);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (unacknowledgedAgents.length > 0) {
|
||||
coreEvents.emitAgentsDiscovered(unacknowledgedAgents);
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
agentsToRegister.map((agent) => this.registerAgent(agent)),
|
||||
projectAgents.agents.map((agent) => this.registerAgent(agent)),
|
||||
);
|
||||
} else {
|
||||
coreEvents.emitFeedback(
|
||||
@@ -304,8 +251,6 @@ export class AgentRegistry {
|
||||
return;
|
||||
}
|
||||
|
||||
this.allDefinitions.set(definition.name, definition);
|
||||
|
||||
const settingsOverrides =
|
||||
this.config.getAgentsSettings().overrides?.[definition.name];
|
||||
|
||||
@@ -360,8 +305,6 @@ export class AgentRegistry {
|
||||
return;
|
||||
}
|
||||
|
||||
this.allDefinitions.set(definition.name, definition);
|
||||
|
||||
const overrides =
|
||||
this.config.getAgentsSettings().overrides?.[definition.name];
|
||||
|
||||
@@ -474,8 +417,7 @@ export class AgentRegistry {
|
||||
/**
|
||||
* Retrieves an agent definition by name.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getDefinition(name: string): AgentDefinition<any> | undefined {
|
||||
getDefinition(name: string): AgentDefinition | undefined {
|
||||
return this.agents.get(name);
|
||||
}
|
||||
|
||||
@@ -493,20 +435,6 @@ export class AgentRegistry {
|
||||
return Array.from(this.agents.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of all discovered agent names, regardless of whether they are enabled.
|
||||
*/
|
||||
getAllDiscoveredAgentNames(): string[] {
|
||||
return Array.from(this.allDefinitions.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a discovered agent definition by name.
|
||||
*/
|
||||
getDiscoveredDefinition(name: string): AgentDefinition | undefined {
|
||||
return this.allDefinitions.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a description for the delegate_to_agent tool.
|
||||
* Unlike getDirectoryContext() which is for system prompts,
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { AgentRegistry } from './registry.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import type { AgentDefinition } from './types.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import * as tomlLoader from './agentLoader.js';
|
||||
import { type Config } from '../config/config.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./agentLoader.js', () => ({
|
||||
loadAgentsFromDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock AcknowledgedAgentsService
|
||||
const mockAckService = {
|
||||
load: vi.fn(),
|
||||
save: vi.fn(),
|
||||
isAcknowledged: vi.fn(),
|
||||
acknowledge: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('./acknowledgedAgents.js', () => ({
|
||||
AcknowledgedAgentsService: {
|
||||
getInstance: vi.fn(() => mockAckService),
|
||||
},
|
||||
}));
|
||||
|
||||
const MOCK_AGENT_WITH_HASH: AgentDefinition = {
|
||||
kind: 'local',
|
||||
name: 'ProjectAgent',
|
||||
description: 'Project Agent Desc',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
modelConfig: {
|
||||
model: 'test',
|
||||
generateContentConfig: { thinkingConfig: { includeThoughts: true } },
|
||||
},
|
||||
runConfig: { maxTimeMinutes: 1 },
|
||||
promptConfig: { systemPrompt: 'test' },
|
||||
metadata: {
|
||||
hash: 'hash123',
|
||||
filePath: '/project/agent.md',
|
||||
},
|
||||
};
|
||||
|
||||
describe.skip('AgentRegistry Acknowledgement', () => {
|
||||
let registry: AgentRegistry;
|
||||
let config: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
config = makeFakeConfig({
|
||||
folderTrust: true,
|
||||
trustedFolder: true,
|
||||
});
|
||||
// Ensure we are in trusted folder mode for project agents to load
|
||||
vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true);
|
||||
vi.spyOn(config, 'getFolderTrust').mockReturnValue(true);
|
||||
vi.spyOn(config, 'getProjectRoot').mockReturnValue('/project');
|
||||
|
||||
// We cannot easily spy on storage.getProjectAgentsDir if it's a property/getter unless we cast to any or it's a method
|
||||
// Assuming it's a method on Storage class
|
||||
vi.spyOn(config.storage, 'getProjectAgentsDir').mockReturnValue(
|
||||
'/project/.gemini/agents',
|
||||
);
|
||||
|
||||
registry = new AgentRegistry(config);
|
||||
|
||||
// Reset mocks
|
||||
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({
|
||||
agents: [],
|
||||
errors: [],
|
||||
});
|
||||
mockAckService.isAcknowledged.mockReturnValue(false);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should not register unacknowledged project agents and emit event', async () => {
|
||||
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({
|
||||
agents: [MOCK_AGENT_WITH_HASH],
|
||||
errors: [],
|
||||
});
|
||||
mockAckService.isAcknowledged.mockReturnValue(false);
|
||||
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emitAgentsDiscovered');
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getDefinition('ProjectAgent')).toBeUndefined();
|
||||
expect(emitSpy).toHaveBeenCalledWith([MOCK_AGENT_WITH_HASH]);
|
||||
});
|
||||
|
||||
it('should register acknowledged project agents', async () => {
|
||||
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({
|
||||
agents: [MOCK_AGENT_WITH_HASH],
|
||||
errors: [],
|
||||
});
|
||||
mockAckService.isAcknowledged.mockReturnValue(true);
|
||||
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emitAgentsDiscovered');
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should register agents without hash (legacy/safe?)', async () => {
|
||||
// Current logic: if no hash, allow it.
|
||||
const agentNoHash = { ...MOCK_AGENT_WITH_HASH, metadata: undefined };
|
||||
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({
|
||||
agents: [agentNoHash],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
await registry.initialize();
|
||||
|
||||
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
|
||||
});
|
||||
|
||||
it('acknowledgeAgent should acknowledge and register agent', async () => {
|
||||
await registry.acknowledgeAgent(MOCK_AGENT_WITH_HASH);
|
||||
|
||||
expect(mockAckService.acknowledge).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'ProjectAgent',
|
||||
'hash123',
|
||||
);
|
||||
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
displayName: 'Test Agent',
|
||||
description: 'A test agent',
|
||||
inputConfig: {
|
||||
inputSchema: { type: 'object' },
|
||||
inputs: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,33 +70,10 @@ describe('RemoteAgentInvocation', () => {
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts missing query (defaults to "Get Started!")', () => {
|
||||
it('throws if query is missing', () => {
|
||||
expect(() => {
|
||||
new RemoteAgentInvocation(mockDefinition, {}, mockMessageBus);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('uses "Get Started!" default when query is missing during execution', async () => {
|
||||
mockClientManager.getClient.mockReturnValue({});
|
||||
mockClientManager.sendMessage.mockResolvedValue({
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'Hello' }],
|
||||
});
|
||||
|
||||
const invocation = new RemoteAgentInvocation(
|
||||
mockDefinition,
|
||||
{},
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(mockClientManager.sendMessage).toHaveBeenCalledWith(
|
||||
'test-agent',
|
||||
'Get Started!',
|
||||
expect.any(Object),
|
||||
);
|
||||
}).toThrow("requires a string 'query' input");
|
||||
});
|
||||
|
||||
it('throws if query is not a string', () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
} from '../tools/tools.js';
|
||||
import { DEFAULT_QUERY_STRING } from './types.js';
|
||||
import type {
|
||||
RemoteAgentInputs,
|
||||
RemoteAgentDefinition,
|
||||
@@ -90,7 +89,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
_toolName?: string,
|
||||
_toolDisplayName?: string,
|
||||
) {
|
||||
const query = params['query'] ?? DEFAULT_QUERY_STRING;
|
||||
const query = params['query'];
|
||||
if (typeof query !== 'string') {
|
||||
throw new Error(
|
||||
`Remote agent '${definition.name}' requires a string 'query' input.`,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertInputConfigToJsonSchema } from './schema-utils.js';
|
||||
import type { InputConfig } from './types.js';
|
||||
|
||||
const PRIMITIVE_TYPES_CONFIG: InputConfig = {
|
||||
inputs: {
|
||||
goal: {
|
||||
type: 'string',
|
||||
description: 'The primary objective',
|
||||
required: true,
|
||||
},
|
||||
max_retries: {
|
||||
type: 'integer',
|
||||
description: 'Maximum number of retries',
|
||||
required: false,
|
||||
},
|
||||
temperature: {
|
||||
type: 'number',
|
||||
description: 'The model temperature',
|
||||
required: true,
|
||||
},
|
||||
verbose: {
|
||||
type: 'boolean',
|
||||
description: 'Enable verbose logging',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ARRAY_TYPES_CONFIG: InputConfig = {
|
||||
inputs: {
|
||||
filenames: {
|
||||
type: 'string[]',
|
||||
description: 'A list of file paths',
|
||||
required: true,
|
||||
},
|
||||
scores: {
|
||||
type: 'number[]',
|
||||
description: 'A list of scores',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const NO_REQUIRED_FIELDS_CONFIG: InputConfig = {
|
||||
inputs: {
|
||||
optional_param: {
|
||||
type: 'string',
|
||||
description: 'An optional parameter',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ALL_REQUIRED_FIELDS_CONFIG: InputConfig = {
|
||||
inputs: {
|
||||
paramA: { type: 'string', description: 'Parameter A', required: true },
|
||||
paramB: { type: 'boolean', description: 'Parameter B', required: true },
|
||||
},
|
||||
};
|
||||
|
||||
const EMPTY_CONFIG: InputConfig = {
|
||||
inputs: {},
|
||||
};
|
||||
|
||||
const UNSUPPORTED_TYPE_CONFIG: InputConfig = {
|
||||
inputs: {
|
||||
invalid_param: {
|
||||
// @ts-expect-error - Intentionally testing an invalid type
|
||||
type: 'date',
|
||||
description: 'This type is not supported',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('convertInputConfigToJsonSchema', () => {
|
||||
describe('type conversion', () => {
|
||||
it('should correctly convert an InputConfig with various primitive types', () => {
|
||||
const result = convertInputConfigToJsonSchema(PRIMITIVE_TYPES_CONFIG);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
goal: { type: 'string', description: 'The primary objective' },
|
||||
max_retries: {
|
||||
type: 'integer',
|
||||
description: 'Maximum number of retries',
|
||||
},
|
||||
temperature: { type: 'number', description: 'The model temperature' },
|
||||
verbose: { type: 'boolean', description: 'Enable verbose logging' },
|
||||
},
|
||||
required: ['goal', 'temperature'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly handle array types for strings and numbers', () => {
|
||||
const result = convertInputConfigToJsonSchema(ARRAY_TYPES_CONFIG);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
filenames: {
|
||||
type: 'array',
|
||||
description: 'A list of file paths',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
scores: {
|
||||
type: 'array',
|
||||
description: 'A list of scores',
|
||||
items: { type: 'number' },
|
||||
},
|
||||
},
|
||||
required: ['filenames'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('required field handling', () => {
|
||||
it('should produce an undefined `required` field when no inputs are required', () => {
|
||||
const result = convertInputConfigToJsonSchema(NO_REQUIRED_FIELDS_CONFIG);
|
||||
|
||||
expect(result.properties['optional_param']).toBeDefined();
|
||||
// Per the implementation and JSON Schema spec, the `required` field
|
||||
// should be omitted if no properties are required.
|
||||
expect(result.required).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should list all properties in `required` when all are marked as required', () => {
|
||||
const result = convertInputConfigToJsonSchema(ALL_REQUIRED_FIELDS_CONFIG);
|
||||
expect(result.required).toHaveLength(2);
|
||||
expect(result.required).toEqual(
|
||||
expect.arrayContaining(['paramA', 'paramB']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return a valid, empty schema for an empty input config', () => {
|
||||
const result = convertInputConfigToJsonSchema(EMPTY_CONFIG);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw an informative error for an unsupported input type', () => {
|
||||
const action = () =>
|
||||
convertInputConfigToJsonSchema(UNSUPPORTED_TYPE_CONFIG);
|
||||
|
||||
expect(action).toThrow(/Unsupported input type 'date'/);
|
||||
expect(action).toThrow(/parameter 'invalid_param'/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { InputConfig } from './types.js';
|
||||
|
||||
/**
|
||||
* Defines the structure for a JSON Schema object, used for tool function
|
||||
* declarations.
|
||||
*/
|
||||
interface JsonSchemaObject {
|
||||
type: 'object';
|
||||
properties: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the structure for a property within a {@link JsonSchemaObject}.
|
||||
*/
|
||||
interface JsonSchemaProperty {
|
||||
type: 'string' | 'number' | 'integer' | 'boolean' | 'array';
|
||||
description: string;
|
||||
items?: { type: 'string' | 'number' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an internal `InputConfig` definition into a standard JSON Schema
|
||||
* object suitable for a tool's `FunctionDeclaration`.
|
||||
*
|
||||
* This utility ensures that the configuration for a subagent's inputs is
|
||||
* correctly translated into the format expected by the generative model.
|
||||
*
|
||||
* @param inputConfig The internal `InputConfig` to convert.
|
||||
* @returns A JSON Schema object representing the inputs.
|
||||
* @throws An `Error` if an unsupported input type is encountered, ensuring
|
||||
* configuration errors are caught early.
|
||||
*/
|
||||
export function convertInputConfigToJsonSchema(
|
||||
inputConfig: InputConfig,
|
||||
): JsonSchemaObject {
|
||||
const properties: Record<string, JsonSchemaProperty> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const [name, definition] of Object.entries(inputConfig.inputs)) {
|
||||
const schemaProperty: Partial<JsonSchemaProperty> = {
|
||||
description: definition.description,
|
||||
};
|
||||
|
||||
switch (definition.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
schemaProperty.type = definition.type;
|
||||
break;
|
||||
|
||||
case 'string[]':
|
||||
schemaProperty.type = 'array';
|
||||
schemaProperty.items = { type: 'string' };
|
||||
break;
|
||||
|
||||
case 'number[]':
|
||||
schemaProperty.type = 'array';
|
||||
schemaProperty.items = { type: 'number' };
|
||||
break;
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = definition.type;
|
||||
throw new Error(
|
||||
`Unsupported input type '${exhaustiveCheck}' for parameter '${name}'. ` +
|
||||
'Supported types: string, number, integer, boolean, string[], number[]',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
properties[name] = schemaProperty as JsonSchemaProperty;
|
||||
|
||||
if (definition.required) {
|
||||
required.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required: required.length > 0 ? required : undefined,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
import { convertInputConfigToJsonSchema } from './schema-utils.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import type { LocalAgentDefinition, AgentInputs } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
@@ -16,8 +17,12 @@ import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
|
||||
// Mock dependencies to isolate the SubagentToolWrapper class
|
||||
vi.mock('./local-invocation.js');
|
||||
vi.mock('./schema-utils.js');
|
||||
|
||||
const MockedLocalSubagentInvocation = vi.mocked(LocalSubagentInvocation);
|
||||
const mockConvertInputConfigToJsonSchema = vi.mocked(
|
||||
convertInputConfigToJsonSchema,
|
||||
);
|
||||
|
||||
// Define reusable test data
|
||||
let mockConfig: Config;
|
||||
@@ -29,16 +34,13 @@ const mockDefinition: LocalAgentDefinition = {
|
||||
displayName: 'Test Agent Display Name',
|
||||
description: 'An agent for testing.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
goal: { type: 'string', description: 'The goal.' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'The priority.',
|
||||
},
|
||||
inputs: {
|
||||
goal: { type: 'string', required: true, description: 'The goal.' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: 'The priority.',
|
||||
},
|
||||
required: ['goal'],
|
||||
},
|
||||
},
|
||||
modelConfig: {
|
||||
@@ -52,14 +54,34 @@ const mockDefinition: LocalAgentDefinition = {
|
||||
promptConfig: { systemPrompt: 'You are a test agent.' },
|
||||
};
|
||||
|
||||
const mockSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
goal: { type: 'string', description: 'The goal.' },
|
||||
priority: { type: 'number', description: 'The priority.' },
|
||||
},
|
||||
required: ['goal'],
|
||||
};
|
||||
|
||||
describe('SubagentToolWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
mockMessageBus = createMockMessageBus();
|
||||
// Provide a mock implementation for the schema conversion utility
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockConvertInputConfigToJsonSchema.mockReturnValue(mockSchema as any);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should call convertInputConfigToJsonSchema with the correct agent inputConfig', () => {
|
||||
new SubagentToolWrapper(mockDefinition, mockConfig, mockMessageBus);
|
||||
|
||||
expect(convertInputConfigToJsonSchema).toHaveBeenCalledExactlyOnceWith(
|
||||
mockDefinition.inputConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly configure the tool properties from the agent definition', () => {
|
||||
const wrapper = new SubagentToolWrapper(
|
||||
mockDefinition,
|
||||
@@ -98,9 +120,7 @@ describe('SubagentToolWrapper', () => {
|
||||
|
||||
expect(schema.name).toBe(mockDefinition.name);
|
||||
expect(schema.description).toBe(mockDefinition.description);
|
||||
expect(schema.parametersJsonSchema).toEqual(
|
||||
mockDefinition.inputConfig.inputSchema,
|
||||
);
|
||||
expect(schema.parametersJsonSchema).toEqual(mockSchema);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../tools/tools.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { AgentDefinition, AgentInputs } from './types.js';
|
||||
import { convertInputConfigToJsonSchema } from './schema-utils.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
import { RemoteAgentInvocation } from './remote-invocation.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
@@ -39,12 +40,16 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
|
||||
private readonly config: Config,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
const parameterSchema = convertInputConfigToJsonSchema(
|
||||
definition.inputConfig,
|
||||
);
|
||||
|
||||
super(
|
||||
definition.name,
|
||||
definition.displayName ?? definition.name,
|
||||
definition.description,
|
||||
Kind.Think,
|
||||
definition.inputConfig.inputSchema,
|
||||
parameterSchema,
|
||||
messageBus,
|
||||
/* isOutputMarkdown */ true,
|
||||
/* canUpdateOutput */ true,
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { Content, FunctionDeclaration } from '@google/genai';
|
||||
import type { AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { type z } from 'zod';
|
||||
import type { ModelConfig } from '../services/modelConfigService.js';
|
||||
import type { AnySchema } from 'ajv';
|
||||
|
||||
/**
|
||||
* Describes the possible termination modes for an agent.
|
||||
@@ -34,11 +33,6 @@ export interface OutputObject {
|
||||
terminate_reason: AgentTerminateMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default query string provided to an agent as input.
|
||||
*/
|
||||
export const DEFAULT_QUERY_STRING = 'Get Started!';
|
||||
|
||||
/**
|
||||
* Represents the validated input parameters passed to an agent upon invocation.
|
||||
* Used primarily for templating the system prompt. (Replaces ContextState)
|
||||
@@ -74,10 +68,6 @@ export interface BaseAgentDefinition<
|
||||
experimental?: boolean;
|
||||
inputConfig: InputConfig;
|
||||
outputConfig?: OutputConfig<TOutput>;
|
||||
metadata?: {
|
||||
hash?: string;
|
||||
filePath?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalAgentDefinition<
|
||||
@@ -147,7 +137,24 @@ export interface ToolConfig {
|
||||
* Configures the expected inputs (parameters) for the agent.
|
||||
*/
|
||||
export interface InputConfig {
|
||||
inputSchema: AnySchema;
|
||||
/**
|
||||
* Defines the parameters the agent accepts.
|
||||
* This is vital for generating the tool wrapper schema.
|
||||
*/
|
||||
inputs: Record<
|
||||
string,
|
||||
{
|
||||
description: string;
|
||||
type:
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'integer'
|
||||
| 'string[]'
|
||||
| 'number[]';
|
||||
required: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -856,9 +856,9 @@ describe('Server Config (config.ts)', () => {
|
||||
});
|
||||
|
||||
describe('Event Driven Scheduler Configuration', () => {
|
||||
it('should default enableEventDrivenScheduler to true when not provided', () => {
|
||||
it('should default enableEventDrivenScheduler to false when not provided', () => {
|
||||
const config = new Config(baseParams);
|
||||
expect(config.isEventDrivenSchedulerEnabled()).toBe(true);
|
||||
expect(config.isEventDrivenSchedulerEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should set enableEventDrivenScheduler to false when provided as false', () => {
|
||||
|
||||
@@ -636,7 +636,8 @@ export class Config {
|
||||
this.agents = params.agents ?? {};
|
||||
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
|
||||
this.planEnabled = params.plan ?? false;
|
||||
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
|
||||
this.enableEventDrivenScheduler =
|
||||
params.enableEventDrivenScheduler ?? false;
|
||||
this.skillsSupport = params.skillsSupport ?? false;
|
||||
this.disabledSkills = params.disabledSkills ?? [];
|
||||
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
|
||||
@@ -685,7 +686,7 @@ export class Config {
|
||||
? false
|
||||
: (params.useWriteTodos ?? true);
|
||||
this.enableHooksUI = params.enableHooksUI ?? true;
|
||||
this.enableHooks = params.enableHooks ?? true;
|
||||
this.enableHooks = params.enableHooks ?? false;
|
||||
this.disabledHooks = params.disabledHooks ?? [];
|
||||
|
||||
this.codebaseInvestigatorSettings = {
|
||||
|
||||
@@ -66,10 +66,6 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'agents');
|
||||
}
|
||||
|
||||
static getAcknowledgedAgentsPath(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'acknowledgedAgents.json');
|
||||
}
|
||||
|
||||
static getSystemSettingsPath(): string {
|
||||
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
||||
|
||||
@@ -188,13 +188,8 @@ describe('SchedulerStateManager', () => {
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
vi.mocked(onUpdate).mockClear();
|
||||
stateManager.updateStatus(call.request.callId, 'success', response);
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(onUpdate).mockClear();
|
||||
stateManager.finalizeCall(call.request.callId);
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(stateManager.isActive).toBe(false);
|
||||
expect(stateManager.completedBatch).toHaveLength(1);
|
||||
@@ -460,7 +455,6 @@ describe('SchedulerStateManager', () => {
|
||||
createValidatingCall('2'),
|
||||
]);
|
||||
|
||||
vi.mocked(onUpdate).mockClear();
|
||||
stateManager.cancelAllQueued('Batch cancel');
|
||||
|
||||
expect(stateManager.queueLength).toBe(0);
|
||||
@@ -468,13 +462,6 @@ describe('SchedulerStateManager', () => {
|
||||
expect(
|
||||
stateManager.completedBatch.every((c) => c.status === 'cancelled'),
|
||||
).toBe(true);
|
||||
expect(onUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not notify if cancelAllQueued is called on an empty queue', () => {
|
||||
vi.mocked(onUpdate).mockClear();
|
||||
stateManager.cancelAllQueued('Batch cancel');
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear batch and notify', () => {
|
||||
|
||||
@@ -130,7 +130,6 @@ export class SchedulerStateManager {
|
||||
if (this.isTerminalCall(call)) {
|
||||
this._completedBatch.push(call);
|
||||
this.activeCalls.delete(callId);
|
||||
this.emitUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,10 +160,6 @@ export class SchedulerStateManager {
|
||||
}
|
||||
|
||||
cancelAllQueued(reason: string): void {
|
||||
if (this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const queuedCall = this.queue.shift()!;
|
||||
if (queuedCall.status === 'error') {
|
||||
|
||||
@@ -328,7 +328,7 @@ export function getErrorReplaceResult(
|
||||
if (occurrences === 0) {
|
||||
error = {
|
||||
display: `Failed to edit, could not find the string to replace.`,
|
||||
raw: `Failed to edit, 0 occurrences found for old_string in ${params.file_path}. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${READ_FILE_TOOL_NAME} tool to verify.`,
|
||||
raw: `Failed to edit, 0 occurrences found for old_string (${finalOldString}). Original old_string was (${params.old_string}) in ${params.file_path}. No edits made. The exact text in old_string was not found. Ensure you're not escaping content incorrectly and check whitespace, indentation, and context. Use ${READ_FILE_TOOL_NAME} tool to verify.`,
|
||||
type: ToolErrorType.EDIT_NO_OCCURRENCE_FOUND,
|
||||
};
|
||||
} else if (occurrences !== expectedReplacements) {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { AgentDefinition } from '../agents/types.js';
|
||||
|
||||
/**
|
||||
* Defines the severity level for user-facing feedback.
|
||||
@@ -109,13 +108,6 @@ export interface RetryAttemptPayload {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the 'agents-discovered' event.
|
||||
*/
|
||||
export interface AgentsDiscoveredPayload {
|
||||
agents: AgentDefinition[];
|
||||
}
|
||||
|
||||
export enum CoreEvent {
|
||||
UserFeedback = 'user-feedback',
|
||||
ModelChanged = 'model-changed',
|
||||
@@ -129,7 +121,6 @@ export enum CoreEvent {
|
||||
AgentsRefreshed = 'agents-refreshed',
|
||||
AdminSettingsChanged = 'admin-settings-changed',
|
||||
RetryAttempt = 'retry-attempt',
|
||||
AgentsDiscovered = 'agents-discovered',
|
||||
}
|
||||
|
||||
export interface CoreEvents {
|
||||
@@ -145,7 +136,6 @@ export interface CoreEvents {
|
||||
[CoreEvent.AgentsRefreshed]: never[];
|
||||
[CoreEvent.AdminSettingsChanged]: never[];
|
||||
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
|
||||
[CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload];
|
||||
}
|
||||
|
||||
type EventBacklogItem = {
|
||||
@@ -268,14 +258,6 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
||||
this.emit(CoreEvent.RetryAttempt, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies subscribers that new unacknowledged agents have been discovered.
|
||||
*/
|
||||
emitAgentsDiscovered(agents: AgentDefinition[]): void {
|
||||
const payload: AgentsDiscoveredPayload = { agents };
|
||||
this._emitOrQueue(CoreEvent.AgentsDiscovered, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes buffered messages. Call this immediately after primary UI listener
|
||||
* subscribes.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import AjvPkg, { type AnySchema } from 'ajv';
|
||||
import AjvPkg from 'ajv';
|
||||
import * as addFormats from 'ajv-formats';
|
||||
// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -47,16 +47,4 @@ export class SchemaValidator {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a JSON schema itself. Returns null if the schema is valid,
|
||||
* otherwise returns a string describing the validation errors.
|
||||
*/
|
||||
static validateSchema(schema: AnySchema | undefined): string | null {
|
||||
if (!schema) {
|
||||
return null;
|
||||
}
|
||||
const isValid = ajValidator.validateSchema(schema);
|
||||
return isValid ? null : ajValidator.errorsText(ajValidator.errors);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1403,8 +1403,8 @@
|
||||
"enableEventDrivenScheduler": {
|
||||
"title": "Event Driven Scheduler",
|
||||
"description": "Enables event-driven scheduler within the CLI session.",
|
||||
"markdownDescription": "Enables event-driven scheduler within the CLI session.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
|
||||
"default": true,
|
||||
"markdownDescription": "Enables event-driven scheduler within the CLI session.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"extensionReloading": {
|
||||
@@ -1574,8 +1574,8 @@
|
||||
"enabled": {
|
||||
"title": "Enable Hooks",
|
||||
"description": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.",
|
||||
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `true`",
|
||||
"default": true,
|
||||
"markdownDescription": "Canonical toggle for the hooks system. When disabled, no hooks will be executed.\n\n- Category: `Advanced`\n- Requires restart: `no`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"disabled": {
|
||||
|
||||
Reference in New Issue
Block a user