mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(agents): implement first-run experience for project-level sub-agents (#17266)
This commit is contained in:
committed by
GitHub
parent
d745d86af1
commit
2271bbb339
@@ -198,6 +198,7 @@ const mockUIActions: UIActions = {
|
|||||||
setEmbeddedShellFocused: vi.fn(),
|
setEmbeddedShellFocused: vi.fn(),
|
||||||
setAuthContext: vi.fn(),
|
setAuthContext: vi.fn(),
|
||||||
handleRestart: vi.fn(),
|
handleRestart: vi.fn(),
|
||||||
|
handleNewAgentsSelect: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const renderWithProviders = (
|
export const renderWithProviders = (
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
SessionStartSource,
|
SessionStartSource,
|
||||||
SessionEndReason,
|
SessionEndReason,
|
||||||
generateSummary,
|
generateSummary,
|
||||||
|
type AgentsDiscoveredPayload,
|
||||||
ChangeAuthRequestedError,
|
ChangeAuthRequestedError,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { validateAuthMethod } from '../config/auth.js';
|
import { validateAuthMethod } from '../config/auth.js';
|
||||||
@@ -133,6 +134,7 @@ import {
|
|||||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||||
|
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||||
import { isSlashCommand } from './utils/commandUtils.js';
|
import { isSlashCommand } from './utils/commandUtils.js';
|
||||||
|
|
||||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||||
@@ -218,6 +220,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
|
||||||
|
|
||||||
const [defaultBannerText, setDefaultBannerText] = useState('');
|
const [defaultBannerText, setDefaultBannerText] = useState('');
|
||||||
const [warningBannerText, setWarningBannerText] = useState('');
|
const [warningBannerText, setWarningBannerText] = useState('');
|
||||||
const [bannerVisible, setBannerVisible] = useState(true);
|
const [bannerVisible, setBannerVisible] = useState(true);
|
||||||
@@ -414,14 +418,20 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
setAdminSettingsChanged(true);
|
setAdminSettingsChanged(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => {
|
||||||
|
setNewAgents(payload.agents);
|
||||||
|
};
|
||||||
|
|
||||||
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
|
coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||||
coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged);
|
coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged);
|
||||||
|
coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
|
||||||
return () => {
|
return () => {
|
||||||
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
|
coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged);
|
||||||
coreEvents.off(
|
coreEvents.off(
|
||||||
CoreEvent.AdminSettingsChanged,
|
CoreEvent.AdminSettingsChanged,
|
||||||
handleAdminSettingsChanged,
|
handleAdminSettingsChanged,
|
||||||
);
|
);
|
||||||
|
coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1564,8 +1574,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
!!proQuotaRequest ||
|
!!proQuotaRequest ||
|
||||||
!!validationRequest ||
|
!!validationRequest ||
|
||||||
isSessionBrowserOpen ||
|
isSessionBrowserOpen ||
|
||||||
isAuthDialogOpen ||
|
authState === AuthState.AwaitingApiKeyInput ||
|
||||||
authState === AuthState.AwaitingApiKeyInput;
|
!!newAgents;
|
||||||
|
|
||||||
const pendingHistoryItems = useMemo(
|
const pendingHistoryItems = useMemo(
|
||||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||||
@@ -1728,6 +1738,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
terminalBackgroundColor: config.getTerminalBackground(),
|
terminalBackgroundColor: config.getTerminalBackground(),
|
||||||
settingsNonce,
|
settingsNonce,
|
||||||
adminSettingsChanged,
|
adminSettingsChanged,
|
||||||
|
newAgents,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isThemeDialogOpen,
|
isThemeDialogOpen,
|
||||||
@@ -1828,6 +1839,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
config,
|
config,
|
||||||
settingsNonce,
|
settingsNonce,
|
||||||
adminSettingsChanged,
|
adminSettingsChanged,
|
||||||
|
newAgents,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1879,6 +1891,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
await runExitCleanup();
|
await runExitCleanup();
|
||||||
process.exit(RELAUNCH_EXIT_CODE);
|
process.exit(RELAUNCH_EXIT_CODE);
|
||||||
},
|
},
|
||||||
|
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
|
||||||
|
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
|
||||||
|
const registry = config.getAgentRegistry();
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
newAgents.map((agent) => registry.acknowledgeAgent(agent)),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error('Failed to acknowledge agents:', error);
|
||||||
|
historyManager.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to acknowledge agents: ${getErrorMessage(error)}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNewAgents(null);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
handleThemeSelect,
|
handleThemeSelect,
|
||||||
@@ -1918,6 +1950,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
setAuthContext,
|
setAuthContext,
|
||||||
|
newAgents,
|
||||||
|
config,
|
||||||
|
historyManager,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import process from 'node:process';
|
|||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
|
||||||
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
||||||
|
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||||
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
import { AgentConfigDialog } from './AgentConfigDialog.js';
|
||||||
|
|
||||||
interface DialogManagerProps {
|
interface DialogManagerProps {
|
||||||
@@ -58,6 +59,14 @@ export const DialogManager = ({
|
|||||||
if (uiState.showIdeRestartPrompt) {
|
if (uiState.showIdeRestartPrompt) {
|
||||||
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
|
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
|
||||||
}
|
}
|
||||||
|
if (uiState.newAgents) {
|
||||||
|
return (
|
||||||
|
<NewAgentsNotification
|
||||||
|
agents={uiState.newAgents}
|
||||||
|
onSelect={uiActions.handleNewAgentsSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (uiState.proQuotaRequest) {
|
if (uiState.proQuotaRequest) {
|
||||||
return (
|
return (
|
||||||
<ProQuotaDialog
|
<ProQuotaDialog
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { renderWithProviders as render } from '../../test-utils/render.js';
|
||||||
|
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||||
|
|
||||||
|
describe('NewAgentsNotification', () => {
|
||||||
|
const mockAgents = [
|
||||||
|
{
|
||||||
|
name: 'Agent A',
|
||||||
|
description: 'Description A',
|
||||||
|
kind: 'remote' as const,
|
||||||
|
agentCardUrl: '',
|
||||||
|
inputConfig: { inputSchema: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Agent B',
|
||||||
|
description: 'Description B',
|
||||||
|
kind: 'remote' as const,
|
||||||
|
agentCardUrl: '',
|
||||||
|
inputConfig: { inputSchema: {} },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
|
||||||
|
it('renders agent list', () => {
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<NewAgentsNotification agents={mockAgents} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const frame = lastFrame();
|
||||||
|
expect(frame).toMatchSnapshot();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates list if more than 5 agents', () => {
|
||||||
|
const manyAgents = Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
name: `Agent ${i}`,
|
||||||
|
description: `Description ${i}`,
|
||||||
|
kind: 'remote' as const,
|
||||||
|
agentCardUrl: '',
|
||||||
|
inputConfig: { inputSchema: {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { lastFrame, unmount } = render(
|
||||||
|
<NewAgentsNotification agents={manyAgents} onSelect={onSelect} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const frame = lastFrame();
|
||||||
|
expect(frame).toMatchSnapshot();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @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 MAX_DISPLAYED_AGENTS = 5;
|
||||||
|
const displayAgents = agents.slice(0, MAX_DISPLAYED_AGENTS);
|
||||||
|
const remaining = agents.length - MAX_DISPLAYED_AGENTS;
|
||||||
|
|
||||||
|
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}
|
||||||
|
borderStyle="single"
|
||||||
|
padding={1}
|
||||||
|
>
|
||||||
|
{displayAgents.map((agent) => (
|
||||||
|
<Box key={agent.name}>
|
||||||
|
<Box flexShrink={0}>
|
||||||
|
<Text bold color={theme.text.primary}>
|
||||||
|
- {agent.name}:{' '}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Text color={theme.text.secondary}> {agent.description}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{remaining > 0 && (
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
... and {remaining} more.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<RadioButtonSelect
|
||||||
|
items={options}
|
||||||
|
onSelect={onSelect}
|
||||||
|
isFocused={true}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`NewAgentsNotification > renders agent list 1`] = `
|
||||||
|
" ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ New Agents Discovered │
|
||||||
|
│ The following agents were found in this project. Please review them: │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ - Agent A: Description A │ │
|
||||||
|
│ │ - Agent B: Description B │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ● 1. Acknowledge and Enable │
|
||||||
|
│ 2. Do not enable (Ask again next time) │
|
||||||
|
│ │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`NewAgentsNotification > truncates list if more than 5 agents 1`] = `
|
||||||
|
" ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ New Agents Discovered │
|
||||||
|
│ The following agents were found in this project. Please review them: │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ - Agent 0: Description 0 │ │
|
||||||
|
│ │ - Agent 1: Description 1 │ │
|
||||||
|
│ │ - Agent 2: Description 2 │ │
|
||||||
|
│ │ - Agent 3: Description 3 │ │
|
||||||
|
│ │ - Agent 4: Description 4 │ │
|
||||||
|
│ │ ... and 2 more. │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ● 1. Acknowledge and Enable │
|
||||||
|
│ 2. Do not enable (Ask again next time) │
|
||||||
|
│ │
|
||||||
|
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
@@ -17,6 +17,7 @@ import { type LoadableSettingScope } from '../../config/settings.js';
|
|||||||
import type { AuthState } from '../types.js';
|
import type { AuthState } from '../types.js';
|
||||||
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
|
import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js';
|
||||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||||
|
import { type NewAgentsChoice } from '../components/NewAgentsNotification.js';
|
||||||
|
|
||||||
export interface UIActions {
|
export interface UIActions {
|
||||||
handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
|
handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void;
|
||||||
@@ -69,6 +70,7 @@ export interface UIActions {
|
|||||||
setEmbeddedShellFocused: (value: boolean) => void;
|
setEmbeddedShellFocused: (value: boolean) => void;
|
||||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||||
handleRestart: () => void;
|
handleRestart: () => void;
|
||||||
|
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export interface UIState {
|
|||||||
terminalBackgroundColor: TerminalBackgroundColor;
|
terminalBackgroundColor: TerminalBackgroundColor;
|
||||||
settingsNonce: number;
|
settingsNonce: number;
|
||||||
adminSettingsChanged: boolean;
|
adminSettingsChanged: boolean;
|
||||||
|
newAgents: AgentDefinition[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIStateContext = createContext<UIState | null>(null);
|
export const UIStateContext = createContext<UIState | null>(null);
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { AcknowledgedAgentsService } from './acknowledgedAgents.js';
|
||||||
|
import { Storage } from '../config/storage.js';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
|
||||||
|
describe('AcknowledgedAgentsService', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let originalGeminiCliHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a unique temp directory for each test
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-'));
|
||||||
|
|
||||||
|
// Override GEMINI_CLI_HOME to point to the temp directory
|
||||||
|
originalGeminiCliHome = process.env['GEMINI_CLI_HOME'];
|
||||||
|
process.env['GEMINI_CLI_HOME'] = tempDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Restore environment variable
|
||||||
|
if (originalGeminiCliHome) {
|
||||||
|
process.env['GEMINI_CLI_HOME'] = originalGeminiCliHome;
|
||||||
|
} else {
|
||||||
|
delete process.env['GEMINI_CLI_HOME'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp directory
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should acknowledge an agent and save to disk', async () => {
|
||||||
|
const service = new AcknowledgedAgentsService();
|
||||||
|
const ackPath = Storage.getAcknowledgedAgentsPath();
|
||||||
|
|
||||||
|
await service.acknowledge('/project', 'AgentA', 'hash1');
|
||||||
|
|
||||||
|
// Verify file exists and content
|
||||||
|
const content = await fs.readFile(ackPath, 'utf-8');
|
||||||
|
expect(content).toContain('"AgentA": "hash1"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for acknowledged agent', async () => {
|
||||||
|
const service = new AcknowledgedAgentsService();
|
||||||
|
|
||||||
|
await service.acknowledge('/project', 'AgentA', 'hash1');
|
||||||
|
|
||||||
|
expect(await service.isAcknowledged('/project', 'AgentA', 'hash1')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(await service.isAcknowledged('/project', 'AgentA', 'hash2')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(await service.isAcknowledged('/project', 'AgentB', 'hash1')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load acknowledged agents from disk', async () => {
|
||||||
|
const ackPath = Storage.getAcknowledgedAgentsPath();
|
||||||
|
const data = {
|
||||||
|
'/project': {
|
||||||
|
AgentLoaded: 'hashLoaded',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
await fs.mkdir(path.dirname(ackPath), { recursive: true });
|
||||||
|
await fs.writeFile(ackPath, JSON.stringify(data), 'utf-8');
|
||||||
|
|
||||||
|
const service = new AcknowledgedAgentsService();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await service.isAcknowledged('/project', 'AgentLoaded', 'hashLoaded'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle load errors gracefully', async () => {
|
||||||
|
// Create a directory where the file should be to cause a read error (EISDIR)
|
||||||
|
const ackPath = Storage.getAcknowledgedAgentsPath();
|
||||||
|
await fs.mkdir(ackPath, { recursive: true });
|
||||||
|
|
||||||
|
const service = new AcknowledgedAgentsService();
|
||||||
|
|
||||||
|
// Should not throw, and treated as empty
|
||||||
|
expect(await service.isAcknowledged('/project', 'Agent', 'hash')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { Storage } from '../config/storage.js';
|
||||||
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
|
import { getErrorMessage, isNodeError } from '../utils/errors.js';
|
||||||
|
|
||||||
|
export interface AcknowledgedAgentsMap {
|
||||||
|
// Project Path -> Agent Name -> Agent Hash
|
||||||
|
[projectPath: string]: {
|
||||||
|
[agentName: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AcknowledgedAgentsService {
|
||||||
|
private acknowledgedAgents: AcknowledgedAgentsMap = {};
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
if (this.loaded) return;
|
||||||
|
|
||||||
|
const filePath = Storage.getAcknowledgedAgentsPath();
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
this.acknowledgedAgents = JSON.parse(content);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isNodeError(error) || error.code !== 'ENOENT') {
|
||||||
|
debugLogger.error(
|
||||||
|
'Failed to load acknowledged agents:',
|
||||||
|
getErrorMessage(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If file doesn't exist or there's a parsing error, fallback to empty
|
||||||
|
this.acknowledgedAgents = {};
|
||||||
|
}
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(): Promise<void> {
|
||||||
|
const filePath = Storage.getAcknowledgedAgentsPath();
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
filePath,
|
||||||
|
JSON.stringify(this.acknowledgedAgents, null, 2),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.error(
|
||||||
|
'Failed to save acknowledged agents:',
|
||||||
|
getErrorMessage(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isAcknowledged(
|
||||||
|
projectPath: string,
|
||||||
|
agentName: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await this.load();
|
||||||
|
const projectAgents = this.acknowledgedAgents[projectPath];
|
||||||
|
if (!projectAgents) return false;
|
||||||
|
return projectAgents[agentName] === hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acknowledge(
|
||||||
|
projectPath: string,
|
||||||
|
agentName: string,
|
||||||
|
hash: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.load();
|
||||||
|
if (!this.acknowledgedAgents[projectPath]) {
|
||||||
|
this.acknowledgedAgents[projectPath] = {};
|
||||||
|
}
|
||||||
|
this.acknowledgedAgents[projectPath][agentName] = hash;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,12 @@ import yaml from 'js-yaml';
|
|||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import { type Dirent } from 'node:fs';
|
import { type Dirent } from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { AgentDefinition } from './types.js';
|
import type { AgentDefinition } from './types.js';
|
||||||
import { isValidToolName } from '../tools/tool-names.js';
|
import { isValidToolName } from '../tools/tool-names.js';
|
||||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||||
|
import { getErrorMessage } from '../utils/errors.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for Markdown parsing - represents the structure from frontmatter.
|
* DTO for Markdown parsing - represents the structure from frontmatter.
|
||||||
@@ -139,24 +141,30 @@ function formatZodError(error: z.ZodError, context: string): string {
|
|||||||
* Parses and validates an agent Markdown file with frontmatter.
|
* Parses and validates an agent Markdown file with frontmatter.
|
||||||
*
|
*
|
||||||
* @param filePath Path to the Markdown file.
|
* @param filePath Path to the Markdown file.
|
||||||
|
* @param content Optional pre-loaded content of the file.
|
||||||
* @returns An array containing the single parsed agent definition.
|
* @returns An array containing the single parsed agent definition.
|
||||||
* @throws AgentLoadError if parsing or validation fails.
|
* @throws AgentLoadError if parsing or validation fails.
|
||||||
*/
|
*/
|
||||||
export async function parseAgentMarkdown(
|
export async function parseAgentMarkdown(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
content?: string,
|
||||||
): Promise<FrontmatterAgentDefinition[]> {
|
): Promise<FrontmatterAgentDefinition[]> {
|
||||||
let content: string;
|
let fileContent: string;
|
||||||
|
if (content !== undefined) {
|
||||||
|
fileContent = content;
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
content = await fs.readFile(filePath, 'utf-8');
|
fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new AgentLoadError(
|
throw new AgentLoadError(
|
||||||
filePath,
|
filePath,
|
||||||
`Could not read file: ${(error as Error).message}`,
|
`Could not read file: ${getErrorMessage(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Split frontmatter and body
|
// Split frontmatter and body
|
||||||
const match = content.match(FRONTMATTER_REGEX);
|
const match = fileContent.match(FRONTMATTER_REGEX);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new AgentLoadError(
|
throw new AgentLoadError(
|
||||||
filePath,
|
filePath,
|
||||||
@@ -229,10 +237,12 @@ export async function parseAgentMarkdown(
|
|||||||
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
||||||
*
|
*
|
||||||
* @param markdown The parsed Markdown/Frontmatter definition.
|
* @param markdown The parsed Markdown/Frontmatter definition.
|
||||||
|
* @param metadata Optional metadata including hash and file path.
|
||||||
* @returns The internal AgentDefinition.
|
* @returns The internal AgentDefinition.
|
||||||
*/
|
*/
|
||||||
export function markdownToAgentDefinition(
|
export function markdownToAgentDefinition(
|
||||||
markdown: FrontmatterAgentDefinition,
|
markdown: FrontmatterAgentDefinition,
|
||||||
|
metadata?: { hash?: string; filePath?: string },
|
||||||
): AgentDefinition {
|
): AgentDefinition {
|
||||||
const inputConfig = {
|
const inputConfig = {
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
@@ -256,6 +266,7 @@ export function markdownToAgentDefinition(
|
|||||||
displayName: markdown.display_name,
|
displayName: markdown.display_name,
|
||||||
agentCardUrl: markdown.agent_card_url,
|
agentCardUrl: markdown.agent_card_url,
|
||||||
inputConfig,
|
inputConfig,
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +299,7 @@ export function markdownToAgentDefinition(
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
inputConfig,
|
inputConfig,
|
||||||
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,9 +346,11 @@ export async function loadAgentsFromDirectory(
|
|||||||
for (const entry of files) {
|
for (const entry of files) {
|
||||||
const filePath = path.join(dir, entry.name);
|
const filePath = path.join(dir, entry.name);
|
||||||
try {
|
try {
|
||||||
const agentDefs = await parseAgentMarkdown(filePath);
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
||||||
|
const agentDefs = await parseAgentMarkdown(filePath, content);
|
||||||
for (const def of agentDefs) {
|
for (const def of agentDefs) {
|
||||||
const agent = markdownToAgentDefinition(def);
|
const agent = markdownToAgentDefinition(def, { hash, filePath });
|
||||||
result.agents.push(agent);
|
result.agents.push(agent);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { SimpleExtensionLoader } from '../utils/extensionLoader.js';
|
|||||||
import type { ConfigParameters } from '../config/config.js';
|
import type { ConfigParameters } from '../config/config.js';
|
||||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||||
import { ThinkingLevel } from '@google/genai';
|
import { ThinkingLevel } from '@google/genai';
|
||||||
|
import type { AcknowledgedAgentsService } from './acknowledgedAgents.js';
|
||||||
|
|
||||||
vi.mock('./agentLoader.js', () => ({
|
vi.mock('./agentLoader.js', () => ({
|
||||||
loadAgentsFromDirectory: vi
|
loadAgentsFromDirectory: vi
|
||||||
@@ -401,6 +402,58 @@ describe('AgentRegistry', () => {
|
|||||||
|
|
||||||
expect(registry.getDefinition('extension-agent')).toBeUndefined();
|
expect(registry.getDefinition('extension-agent')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use agentCardUrl as hash for acknowledgement of remote agents', async () => {
|
||||||
|
mockConfig = makeMockedConfig({ enableAgents: true });
|
||||||
|
// Trust the folder so it attempts to load project agents
|
||||||
|
vi.spyOn(mockConfig, 'isTrustedFolder').mockReturnValue(true);
|
||||||
|
vi.spyOn(mockConfig, 'getFolderTrust').mockReturnValue(true);
|
||||||
|
|
||||||
|
const registry = new TestableAgentRegistry(mockConfig);
|
||||||
|
|
||||||
|
const remoteAgent: AgentDefinition = {
|
||||||
|
kind: 'remote',
|
||||||
|
name: 'RemoteAgent',
|
||||||
|
description: 'A remote agent',
|
||||||
|
agentCardUrl: 'https://example.com/card',
|
||||||
|
inputConfig: { inputSchema: { type: 'object' } },
|
||||||
|
metadata: { hash: 'file-hash', filePath: 'path/to/file.md' },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({
|
||||||
|
agents: [remoteAgent],
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ackService = {
|
||||||
|
isAcknowledged: vi.fn().mockResolvedValue(true),
|
||||||
|
acknowledge: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.spyOn(mockConfig, 'getAcknowledgedAgentsService').mockReturnValue(
|
||||||
|
ackService as unknown as AcknowledgedAgentsService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock A2AClientManager to avoid network calls
|
||||||
|
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
|
||||||
|
loadAgent: vi.fn().mockResolvedValue({ name: 'RemoteAgent' }),
|
||||||
|
clearCache: vi.fn(),
|
||||||
|
} as unknown as A2AClientManager);
|
||||||
|
|
||||||
|
await registry.initialize();
|
||||||
|
|
||||||
|
// Verify ackService was called with the URL, not the file hash
|
||||||
|
expect(ackService.isAcknowledged).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
'RemoteAgent',
|
||||||
|
'https://example.com/card',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also verify that the agent's metadata was updated to use the URL as hash
|
||||||
|
// Use getDefinition because registerAgent might have been called
|
||||||
|
expect(registry.getDefinition('RemoteAgent')?.metadata?.hash).toBe(
|
||||||
|
'https://example.com/card',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registration logic', () => {
|
describe('registration logic', () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Storage } from '../config/storage.js';
|
import { Storage } from '../config/storage.js';
|
||||||
import { coreEvents, CoreEvent } from '../utils/events.js';
|
import { CoreEvent, coreEvents } from '../utils/events.js';
|
||||||
import type { AgentOverride, Config } from '../config/config.js';
|
import type { AgentOverride, Config } from '../config/config.js';
|
||||||
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
|
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
|
||||||
import { loadAgentsFromDirectory } from './agentLoader.js';
|
import { loadAgentsFromDirectory } from './agentLoader.js';
|
||||||
@@ -73,6 +73,23 @@ export class AgentRegistry {
|
|||||||
coreEvents.emitAgentsRefreshed();
|
coreEvents.emitAgentsRefreshed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acknowledges and registers a previously unacknowledged agent.
|
||||||
|
*/
|
||||||
|
async acknowledgeAgent(agent: AgentDefinition): Promise<void> {
|
||||||
|
const ackService = this.config.getAcknowledgedAgentsService();
|
||||||
|
const projectRoot = this.config.getProjectRoot();
|
||||||
|
if (agent.metadata?.hash) {
|
||||||
|
await ackService.acknowledge(
|
||||||
|
projectRoot,
|
||||||
|
agent.name,
|
||||||
|
agent.metadata.hash,
|
||||||
|
);
|
||||||
|
await this.registerAgent(agent);
|
||||||
|
coreEvents.emitAgentsRefreshed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disposes of resources and removes event listeners.
|
* Disposes of resources and removes event listeners.
|
||||||
*/
|
*/
|
||||||
@@ -115,8 +132,46 @@ export class AgentRegistry {
|
|||||||
`Agent loading error: ${error.message}`,
|
`Agent loading error: ${error.message}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ackService = this.config.getAcknowledgedAgentsService();
|
||||||
|
const projectRoot = this.config.getProjectRoot();
|
||||||
|
const unacknowledgedAgents: AgentDefinition[] = [];
|
||||||
|
const agentsToRegister: AgentDefinition[] = [];
|
||||||
|
|
||||||
|
for (const agent of projectAgents.agents) {
|
||||||
|
// If it's a remote agent, use the agentCardUrl as the hash.
|
||||||
|
// This allows multiple remote agents in a single file to be tracked independently.
|
||||||
|
if (agent.kind === 'remote') {
|
||||||
|
if (!agent.metadata) {
|
||||||
|
agent.metadata = {};
|
||||||
|
}
|
||||||
|
agent.metadata.hash = agent.agentCardUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent.metadata?.hash) {
|
||||||
|
agentsToRegister.push(agent);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAcknowledged = await ackService.isAcknowledged(
|
||||||
|
projectRoot,
|
||||||
|
agent.name,
|
||||||
|
agent.metadata.hash,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isAcknowledged) {
|
||||||
|
agentsToRegister.push(agent);
|
||||||
|
} else {
|
||||||
|
unacknowledgedAgents.push(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unacknowledgedAgents.length > 0) {
|
||||||
|
coreEvents.emitAgentsDiscovered(unacknowledgedAgents);
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
projectAgents.agents.map((agent) => this.registerAgent(agent)),
|
agentsToRegister.map((agent) => this.registerAgent(agent)),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
coreEvents.emitFeedback(
|
coreEvents.emitFeedback(
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* @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';
|
||||||
|
import { AcknowledgedAgentsService } from './acknowledgedAgents.js';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('./agentLoader.js', () => ({
|
||||||
|
loadAgentsFromDirectory: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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('AgentRegistry Acknowledgement', () => {
|
||||||
|
let registry: AgentRegistry;
|
||||||
|
let config: Config;
|
||||||
|
let tempDir: string;
|
||||||
|
let originalGeminiCliHome: string | undefined;
|
||||||
|
let ackService: AcknowledgedAgentsService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a unique temp directory for each test
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-'));
|
||||||
|
|
||||||
|
// Override GEMINI_CLI_HOME to point to the temp directory
|
||||||
|
originalGeminiCliHome = process.env['GEMINI_CLI_HOME'];
|
||||||
|
process.env['GEMINI_CLI_HOME'] = tempDir;
|
||||||
|
|
||||||
|
ackService = new AcknowledgedAgentsService();
|
||||||
|
|
||||||
|
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');
|
||||||
|
vi.spyOn(config, 'getAcknowledgedAgentsService').mockReturnValue(
|
||||||
|
ackService,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
);
|
||||||
|
vi.spyOn(config, 'isAgentsEnabled').mockReturnValue(true);
|
||||||
|
|
||||||
|
registry = new AgentRegistry(config);
|
||||||
|
|
||||||
|
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockImplementation(
|
||||||
|
async (dir) => {
|
||||||
|
if (dir === '/project/.gemini/agents') {
|
||||||
|
return {
|
||||||
|
agents: [MOCK_AGENT_WITH_HASH],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { agents: [], errors: [] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
|
||||||
|
// Restore environment variable
|
||||||
|
if (originalGeminiCliHome) {
|
||||||
|
process.env['GEMINI_CLI_HOME'] = originalGeminiCliHome;
|
||||||
|
} else {
|
||||||
|
delete process.env['GEMINI_CLI_HOME'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp directory
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not register unacknowledged project agents and emit event', async () => {
|
||||||
|
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 () => {
|
||||||
|
// Acknowledge the agent explicitly
|
||||||
|
await ackService.acknowledge('/project', 'ProjectAgent', 'hash123');
|
||||||
|
|
||||||
|
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockImplementation(
|
||||||
|
async (dir) => {
|
||||||
|
if (dir === '/project/.gemini/agents') {
|
||||||
|
return {
|
||||||
|
agents: [MOCK_AGENT_WITH_HASH],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { agents: [], errors: [] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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).mockImplementation(
|
||||||
|
async (dir) => {
|
||||||
|
if (dir === '/project/.gemini/agents') {
|
||||||
|
return {
|
||||||
|
agents: [agentNoHash],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { agents: [], errors: [] };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await registry.initialize();
|
||||||
|
|
||||||
|
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acknowledgeAgent should acknowledge and register agent', async () => {
|
||||||
|
await registry.acknowledgeAgent(MOCK_AGENT_WITH_HASH);
|
||||||
|
|
||||||
|
// Verify against real service state
|
||||||
|
expect(
|
||||||
|
await ackService.isAcknowledged('/project', 'ProjectAgent', 'hash123'),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,6 +74,10 @@ export interface BaseAgentDefinition<
|
|||||||
experimental?: boolean;
|
experimental?: boolean;
|
||||||
inputConfig: InputConfig;
|
inputConfig: InputConfig;
|
||||||
outputConfig?: OutputConfig<TOutput>;
|
outputConfig?: OutputConfig<TOutput>;
|
||||||
|
metadata?: {
|
||||||
|
hash?: string;
|
||||||
|
filePath?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalAgentDefinition<
|
export interface LocalAgentDefinition<
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import type { FetchAdminControlsResponse } from '../code_assist/types.js';
|
|||||||
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
|
import { getCodeAssistServer } from '../code_assist/codeAssist.js';
|
||||||
import type { Experiments } from '../code_assist/experiments/experiments.js';
|
import type { Experiments } from '../code_assist/experiments/experiments.js';
|
||||||
import { AgentRegistry } from '../agents/registry.js';
|
import { AgentRegistry } from '../agents/registry.js';
|
||||||
|
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
|
||||||
import { setGlobalProxy } from '../utils/fetch.js';
|
import { setGlobalProxy } from '../utils/fetch.js';
|
||||||
import { SubagentTool } from '../agents/subagent-tool.js';
|
import { SubagentTool } from '../agents/subagent-tool.js';
|
||||||
import { getExperiments } from '../code_assist/experiments/experiments.js';
|
import { getExperiments } from '../code_assist/experiments/experiments.js';
|
||||||
@@ -416,6 +417,7 @@ export class Config {
|
|||||||
private promptRegistry!: PromptRegistry;
|
private promptRegistry!: PromptRegistry;
|
||||||
private resourceRegistry!: ResourceRegistry;
|
private resourceRegistry!: ResourceRegistry;
|
||||||
private agentRegistry!: AgentRegistry;
|
private agentRegistry!: AgentRegistry;
|
||||||
|
private readonly acknowledgedAgentsService: AcknowledgedAgentsService;
|
||||||
private skillManager!: SkillManager;
|
private skillManager!: SkillManager;
|
||||||
private sessionId: string;
|
private sessionId: string;
|
||||||
private clientVersion: string;
|
private clientVersion: string;
|
||||||
@@ -705,6 +707,7 @@ export class Config {
|
|||||||
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
|
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
|
||||||
});
|
});
|
||||||
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
|
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
|
||||||
|
this.acknowledgedAgentsService = new AcknowledgedAgentsService();
|
||||||
this.skillManager = new SkillManager();
|
this.skillManager = new SkillManager();
|
||||||
this.outputSettings = {
|
this.outputSettings = {
|
||||||
format: params.output?.format ?? OutputFormat.TEXT,
|
format: params.output?.format ?? OutputFormat.TEXT,
|
||||||
@@ -1138,6 +1141,10 @@ export class Config {
|
|||||||
return this.agentRegistry;
|
return this.agentRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAcknowledgedAgentsService(): AcknowledgedAgentsService {
|
||||||
|
return this.acknowledgedAgentsService;
|
||||||
|
}
|
||||||
|
|
||||||
getToolRegistry(): ToolRegistry {
|
getToolRegistry(): ToolRegistry {
|
||||||
return this.toolRegistry;
|
return this.toolRegistry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ export class Storage {
|
|||||||
return path.join(Storage.getGlobalGeminiDir(), 'agents');
|
return path.join(Storage.getGlobalGeminiDir(), 'agents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getAcknowledgedAgentsPath(): string {
|
||||||
|
return path.join(
|
||||||
|
Storage.getGlobalGeminiDir(),
|
||||||
|
'acknowledgments',
|
||||||
|
'agents.json',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static getSystemSettingsPath(): string {
|
static getSystemSettingsPath(): string {
|
||||||
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) {
|
||||||
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { AgentDefinition } from '../agents/types.js';
|
||||||
import type { McpClient } from '../tools/mcp-client.js';
|
import type { McpClient } from '../tools/mcp-client.js';
|
||||||
import type { ExtensionEvents } from './extensionLoader.js';
|
import type { ExtensionEvents } from './extensionLoader.js';
|
||||||
|
|
||||||
@@ -110,6 +111,13 @@ export interface RetryAttemptPayload {
|
|||||||
model: string;
|
model: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload for the 'agents-discovered' event.
|
||||||
|
*/
|
||||||
|
export interface AgentsDiscoveredPayload {
|
||||||
|
agents: AgentDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
export enum CoreEvent {
|
export enum CoreEvent {
|
||||||
UserFeedback = 'user-feedback',
|
UserFeedback = 'user-feedback',
|
||||||
ModelChanged = 'model-changed',
|
ModelChanged = 'model-changed',
|
||||||
@@ -125,6 +133,7 @@ export enum CoreEvent {
|
|||||||
AgentsRefreshed = 'agents-refreshed',
|
AgentsRefreshed = 'agents-refreshed',
|
||||||
AdminSettingsChanged = 'admin-settings-changed',
|
AdminSettingsChanged = 'admin-settings-changed',
|
||||||
RetryAttempt = 'retry-attempt',
|
RetryAttempt = 'retry-attempt',
|
||||||
|
AgentsDiscovered = 'agents-discovered',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoreEvents extends ExtensionEvents {
|
export interface CoreEvents extends ExtensionEvents {
|
||||||
@@ -142,6 +151,7 @@ export interface CoreEvents extends ExtensionEvents {
|
|||||||
[CoreEvent.AgentsRefreshed]: never[];
|
[CoreEvent.AgentsRefreshed]: never[];
|
||||||
[CoreEvent.AdminSettingsChanged]: never[];
|
[CoreEvent.AdminSettingsChanged]: never[];
|
||||||
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
|
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
|
||||||
|
[CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload];
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventBacklogItem = {
|
type EventBacklogItem = {
|
||||||
@@ -264,6 +274,14 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
|
|||||||
this.emit(CoreEvent.RetryAttempt, payload);
|
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
|
* Flushes buffered messages. Call this immediately after primary UI listener
|
||||||
* subscribes.
|
* subscribes.
|
||||||
|
|||||||
Reference in New Issue
Block a user