mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-25 17:21:01 -07:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d31e3e881 | |||
| 46cca52edc | |||
| ed0b0fae49 | |||
| f0f705d3ca | |||
| f42b4c80ac | |||
| b99e841021 | |||
| a16d5983cf | |||
| e5745f16cb | |||
| 12b0fe1cc2 | |||
| 5b8a23979f | |||
| b71fe94e0a | |||
| e92f60b4fc | |||
| 15f26175b8 | |||
| 85b17166a5 | |||
| 88df6210eb | |||
| 943481ccc0 | |||
| 79076d1d52 | |||
| 166e04a8dd |
@@ -17,6 +17,11 @@ policies.
|
||||
may prompt you to switch to a fallback model (by default always prompts
|
||||
you).
|
||||
|
||||
Some internal utility calls (such as prompt completion and classification)
|
||||
use a silent fallback chain for `gemini-2.5-flash-lite` and will fall back
|
||||
to `gemini-2.5-flash` and `gemini-2.5-pro` without prompting or changing the
|
||||
configured model.
|
||||
|
||||
3. **Model switch:** If approved, or if the policy allows for silent fallback,
|
||||
the CLI will use an available fallback model for the current turn or the
|
||||
remainder of the session.
|
||||
|
||||
@@ -18,6 +18,7 @@ Learn how to enable and setup OpenTelemetry for Gemini CLI.
|
||||
- [Logs and metrics](#logs-and-metrics)
|
||||
- [Logs](#logs)
|
||||
- [Sessions](#sessions)
|
||||
- [Approval Mode](#approval-mode)
|
||||
- [Tools](#tools)
|
||||
- [Files](#files)
|
||||
- [API](#api)
|
||||
@@ -315,6 +316,20 @@ Captures startup configuration and user prompt submissions.
|
||||
- `prompt` (string; excluded if `telemetry.logPrompts` is `false`)
|
||||
- `auth_type` (string)
|
||||
|
||||
#### Approval Mode
|
||||
|
||||
Tracks changes and duration of approval modes.
|
||||
|
||||
- `approval_mode_switch`: Approval mode was changed.
|
||||
- **Attributes**:
|
||||
- `from_mode` (string)
|
||||
- `to_mode` (string)
|
||||
|
||||
- `approval_mode_duration`: Duration spent in an approval mode.
|
||||
- **Attributes**:
|
||||
- `mode` (string)
|
||||
- `duration_ms` (int)
|
||||
|
||||
#### Tools
|
||||
|
||||
Captures tool executions, output truncation, and Edit behavior.
|
||||
|
||||
@@ -68,6 +68,10 @@ If you are using the default "pro" model and the CLI detects that you are being
|
||||
rate-limited, it automatically switches to the "flash" model for the current
|
||||
session. This allows you to continue working without interruption.
|
||||
|
||||
Internal utility calls that use `gemini-2.5-flash-lite` (for example, prompt
|
||||
completion and classification) silently fall back to `gemini-2.5-flash` and
|
||||
`gemini-2.5-pro` when quota is exhausted, without changing the configured model.
|
||||
|
||||
## File discovery service
|
||||
|
||||
The file discovery service is responsible for finding files in the project that
|
||||
|
||||
@@ -304,6 +304,16 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
// Examples should have access to standard globals like fetch
|
||||
{
|
||||
files: ['packages/cli/src/commands/extensions/examples/**/*.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
fetch: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
// extra settings for scripts that we run directly with node
|
||||
{
|
||||
files: ['packages/vscode-ide-companion/scripts/**/*.js'],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
describe('generalist_agent', () => {
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
name: 'should be able to use generalist agent by explicitly asking the main agent to invoke it',
|
||||
params: {
|
||||
settings: {
|
||||
agents: {
|
||||
overrides: {
|
||||
generalist: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt:
|
||||
'Please use the generalist agent to create a file called "generalist_test_file.txt" containing exactly the following text: success',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify the generalist agent was invoked via delegate_to_agent
|
||||
const foundToolCall = await rig.waitForToolCall(
|
||||
'delegate_to_agent',
|
||||
undefined,
|
||||
(args) => {
|
||||
const parsed = JSON.parse(args);
|
||||
return parsed.agent_name === 'generalist';
|
||||
},
|
||||
);
|
||||
expect(
|
||||
foundToolCall,
|
||||
'Expected to find a delegate_to_agent tool call for generalist agent',
|
||||
).toBeTruthy();
|
||||
|
||||
// 2) Verify the file was created as expected
|
||||
const filePath = path.join(rig.testDir!, 'generalist_test_file.txt');
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
expect(content.trim()).toBe('success');
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('subagent eval test cases', () => {
|
||||
*
|
||||
* This tests the system prompt's subagent specific clauses.
|
||||
*/
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should delegate to user provided agent with relevant expertise',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('Hooks System Integration', () => {
|
||||
|
||||
describe('Command Hooks - Blocking Behavior', () => {
|
||||
it('should block tool execution when hook returns block decision', async () => {
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should block tool execution when hook returns block decision',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
@@ -75,8 +75,65 @@ describe('Hooks System Integration', () => {
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should block tool execution and use stderr as reason when hook exits with code 2', async () => {
|
||||
rig.setup(
|
||||
'should block tool execution and use stderr as reason when hook exits with code 2',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.block-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
// Exit with code 2 and write reason to stderr
|
||||
command:
|
||||
'node -e "process.stderr.write(\'File writing blocked by security policy\'); process.exit(2)"',
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await rig.run({
|
||||
args: 'Create a file called test.txt with content "Hello World"',
|
||||
});
|
||||
|
||||
// The hook should block the write_file tool
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const writeFileCalls = toolLogs.filter(
|
||||
(t) =>
|
||||
t.toolRequest.name === 'write_file' && t.toolRequest.success === true,
|
||||
);
|
||||
|
||||
// Tool should not be called due to blocking hook
|
||||
expect(writeFileCalls).toHaveLength(0);
|
||||
|
||||
// Result should mention the blocking reason from stderr
|
||||
expect(result).toContain('File writing blocked by security policy');
|
||||
|
||||
// Verify hook telemetry shows exit code 2 and stderr
|
||||
const hookLogs = rig.readHookLogs();
|
||||
const blockHook = hookLogs.find((log) => log.hookCall.exit_code === 2);
|
||||
expect(blockHook).toBeDefined();
|
||||
expect(blockHook?.hookCall.stderr).toContain(
|
||||
'File writing blocked by security policy',
|
||||
);
|
||||
expect(blockHook?.hookCall.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow tool execution when hook returns allow decision', async () => {
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should allow tool execution when hook returns allow decision',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
@@ -126,7 +183,7 @@ describe('Hooks System Integration', () => {
|
||||
it('should add additional context from AfterTool hooks', async () => {
|
||||
const command =
|
||||
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'AfterTool', additionalContext: 'Security scan: File content appears safe'}}))\"";
|
||||
await rig.setup('should add additional context from AfterTool hooks', {
|
||||
rig.setup('should add additional context from AfterTool hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.after-tool-context.responses',
|
||||
@@ -178,7 +235,7 @@ describe('Hooks System Integration', () => {
|
||||
it('should modify LLM requests with BeforeModel hooks', async () => {
|
||||
// Create a hook script that replaces the LLM request with a modified version
|
||||
// Note: Providing messages in the hook output REPLACES the entire conversation
|
||||
await rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-model.responses',
|
||||
@@ -203,7 +260,7 @@ console.log(JSON.stringify({
|
||||
const scriptPath = join(rig.testDir!, 'before_model_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
rig.setup('should modify LLM requests with BeforeModel hooks', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
@@ -248,13 +305,99 @@ console.log(JSON.stringify({
|
||||
expect(hookTelemetryFound[0].hookCall.stdout).toBeDefined();
|
||||
expect(hookTelemetryFound[0].hookCall.stderr).toBeDefined();
|
||||
});
|
||||
|
||||
it('should block model execution when BeforeModel hook returns deny decision', async () => {
|
||||
rig.setup(
|
||||
'should block model execution when BeforeModel hook returns deny decision',
|
||||
);
|
||||
const hookScript = `console.log(JSON.stringify({
|
||||
decision: "deny",
|
||||
reason: "Model execution blocked by security policy"
|
||||
}));`;
|
||||
const scriptPath = join(rig.testDir!, 'before_model_deny_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
rig.setup(
|
||||
'should block model execution when BeforeModel hook returns deny decision',
|
||||
{
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeModel: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await rig.run({ args: 'Hello' });
|
||||
|
||||
// The hook should have blocked the request
|
||||
expect(result).toContain('Model execution blocked by security policy');
|
||||
|
||||
// Verify no API requests were made to the LLM
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
expect(apiRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should block model execution when BeforeModel hook returns block decision', async () => {
|
||||
rig.setup(
|
||||
'should block model execution when BeforeModel hook returns block decision',
|
||||
);
|
||||
const hookScript = `console.log(JSON.stringify({
|
||||
decision: "block",
|
||||
reason: "Model execution blocked by security policy"
|
||||
}));`;
|
||||
const scriptPath = join(rig.testDir!, 'before_model_block_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
rig.setup(
|
||||
'should block model execution when BeforeModel hook returns block decision',
|
||||
{
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeModel: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${scriptPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await rig.run({ args: 'Hello' });
|
||||
|
||||
// The hook should have blocked the request
|
||||
expect(result).toContain('Model execution blocked by security policy');
|
||||
|
||||
// Verify no API requests were made to the LLM
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
expect(apiRequests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AfterModel Hooks - LLM Response Modification', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should modify LLM responses with AfterModel hooks',
|
||||
async () => {
|
||||
await rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.after-model.responses',
|
||||
@@ -284,7 +427,7 @@ console.log(JSON.stringify({
|
||||
const scriptPath = join(rig.testDir!, 'after_model_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
rig.setup('should modify LLM responses with AfterModel hooks', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
@@ -319,41 +462,35 @@ console.log(JSON.stringify({
|
||||
|
||||
describe('BeforeToolSelection Hooks - Tool Configuration', () => {
|
||||
it('should modify tool selection with BeforeToolSelection hooks', async () => {
|
||||
await rig.setup(
|
||||
'should modify tool selection with BeforeToolSelection hooks',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-tool-selection.responses',
|
||||
),
|
||||
},
|
||||
);
|
||||
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-tool-selection.responses',
|
||||
),
|
||||
});
|
||||
// Create inline hook command (works on both Unix and Windows)
|
||||
const hookCommand =
|
||||
"node -e \"console.log(JSON.stringify({hookSpecificOutput: {hookEventName: 'BeforeToolSelection', toolConfig: {mode: 'ANY', allowedFunctionNames: ['read_file', 'run_shell_command']}}}))\"";
|
||||
|
||||
await rig.setup(
|
||||
'should modify tool selection with BeforeToolSelection hooks',
|
||||
{
|
||||
settings: {
|
||||
debugMode: true,
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeToolSelection: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: hookCommand,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
rig.setup('should modify tool selection with BeforeToolSelection hooks', {
|
||||
settings: {
|
||||
debugMode: true,
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeToolSelection: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: hookCommand,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Create a test file
|
||||
rig.createFile('new_file_data.txt', 'test data');
|
||||
@@ -382,7 +519,7 @@ console.log(JSON.stringify({
|
||||
|
||||
describe('BeforeAgent Hooks - Prompt Augmentation', () => {
|
||||
it('should augment prompts with BeforeAgent hooks', async () => {
|
||||
await rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-agent.responses',
|
||||
@@ -401,7 +538,7 @@ console.log(JSON.stringify({
|
||||
const scriptPath = join(rig.testDir!, 'before_agent_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
rig.setup('should augment prompts with BeforeAgent hooks', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
@@ -438,7 +575,7 @@ console.log(JSON.stringify({
|
||||
const hookCommand =
|
||||
'node -e "console.log(JSON.stringify({suppressOutput: false, systemMessage: \'Permission request logged by security hook\'}))"';
|
||||
|
||||
await rig.setup('should handle notification hooks for tool permissions', {
|
||||
rig.setup('should handle notification hooks for tool permissions', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.notification.responses',
|
||||
@@ -534,7 +671,7 @@ console.log(JSON.stringify({
|
||||
const hook2Command =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'Step 2: Security check completed.'}}))\"";
|
||||
|
||||
await rig.setup('should execute hooks sequentially when configured', {
|
||||
rig.setup('should execute hooks sequentially when configured', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.sequential-execution.responses',
|
||||
@@ -594,7 +731,7 @@ console.log(JSON.stringify({
|
||||
|
||||
describe('Hook Input/Output Validation', () => {
|
||||
it('should provide correct input format to hooks', async () => {
|
||||
await rig.setup('should provide correct input format to hooks', {
|
||||
rig.setup('should provide correct input format to hooks', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.input-validation.responses',
|
||||
@@ -618,7 +755,7 @@ try {
|
||||
const scriptPath = join(rig.testDir!, 'input_validation_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should provide correct input format to hooks', {
|
||||
rig.setup('should provide correct input format to hooks', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
@@ -653,6 +790,50 @@ try {
|
||||
const hookTelemetryFound = await rig.waitForTelemetryEvent('hook_call');
|
||||
expect(hookTelemetryFound).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0', async () => {
|
||||
rig.setup(
|
||||
'should treat mixed stdout (text + JSON) as system message and allow execution when exit code is 0',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.allow-tool.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
// Output plain text then JSON.
|
||||
// This breaks JSON parsing, so it falls back to 'allow' with the whole stdout as systemMessage.
|
||||
command:
|
||||
"node -e \"console.log('Pollution'); console.log(JSON.stringify({decision: 'deny', reason: 'Should be ignored'}))\"",
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await rig.run({
|
||||
args: 'Create a file called approved.txt with content "Approved content"',
|
||||
});
|
||||
|
||||
// The hook logic fails to parse JSON, so it allows the tool.
|
||||
const foundWriteFile = await rig.waitForToolCall('write_file');
|
||||
expect(foundWriteFile).toBeTruthy();
|
||||
|
||||
// The entire stdout (including the JSON part) becomes the systemMessage
|
||||
expect(result).toContain('Pollution');
|
||||
expect(result).toContain('Should be ignored');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Event Types', () => {
|
||||
@@ -665,7 +846,7 @@ try {
|
||||
const beforeAgentCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', hookSpecificOutput: {hookEventName: 'BeforeAgent', additionalContext: 'BeforeAgent: User request processed'}}))\"";
|
||||
|
||||
await rig.setup('should handle hooks for all major event types', {
|
||||
rig.setup('should handle hooks for all major event types', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.multiple-events.responses',
|
||||
@@ -768,7 +949,7 @@ try {
|
||||
|
||||
describe('Hook Error Handling', () => {
|
||||
it('should handle hook failures gracefully', async () => {
|
||||
await rig.setup('should handle hook failures gracefully', {
|
||||
rig.setup('should handle hook failures gracefully', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.error-handling.responses',
|
||||
@@ -782,7 +963,7 @@ try {
|
||||
const workingCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Working hook succeeded'}))\"";
|
||||
|
||||
await rig.setup('should handle hook failures gracefully', {
|
||||
rig.setup('should handle hook failures gracefully', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
@@ -830,7 +1011,7 @@ try {
|
||||
const hookCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', reason: 'Telemetry test hook'}))\"";
|
||||
|
||||
await rig.setup('should generate telemetry events for hook executions', {
|
||||
rig.setup('should generate telemetry events for hook executions', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.telemetry.responses',
|
||||
@@ -871,7 +1052,7 @@ try {
|
||||
const sessionStartCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting on startup'}))\"";
|
||||
|
||||
await rig.setup('should fire SessionStart hook on app startup', {
|
||||
rig.setup('should fire SessionStart hook on app startup', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.session-startup.responses',
|
||||
@@ -936,7 +1117,7 @@ console.log(JSON.stringify({
|
||||
}
|
||||
}));`;
|
||||
|
||||
await rig.setup('should fire SessionStart hook and inject context', {
|
||||
rig.setup('should fire SessionStart hook and inject context', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.session-startup.responses',
|
||||
@@ -946,7 +1127,7 @@ console.log(JSON.stringify({
|
||||
const scriptPath = join(rig.testDir!, 'session_start_context_hook.cjs');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup('should fire SessionStart hook and inject context', {
|
||||
rig.setup('should fire SessionStart hook and inject context', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
@@ -1011,7 +1192,7 @@ console.log(JSON.stringify({
|
||||
}
|
||||
}));`;
|
||||
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should fire SessionStart hook and display systemMessage in interactive mode',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
@@ -1027,7 +1208,7 @@ console.log(JSON.stringify({
|
||||
);
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should fire SessionStart hook and display systemMessage in interactive mode',
|
||||
{
|
||||
settings: {
|
||||
@@ -1091,7 +1272,7 @@ console.log(JSON.stringify({
|
||||
const sessionStartCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'Session starting after clear'}))\"";
|
||||
|
||||
await rig.setup(
|
||||
rig.setup(
|
||||
'should fire SessionEnd and SessionStart hooks on /clear command',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
@@ -1265,7 +1446,7 @@ console.log(JSON.stringify({
|
||||
const preCompressCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'PreCompress hook executed for automatic compression'}))\"";
|
||||
|
||||
await rig.setup('should fire PreCompress hook on automatic compression', {
|
||||
rig.setup('should fire PreCompress hook on automatic compression', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.compress-auto.responses',
|
||||
@@ -1330,7 +1511,7 @@ console.log(JSON.stringify({
|
||||
const sessionEndCommand =
|
||||
"node -e \"console.log(JSON.stringify({decision: 'allow', systemMessage: 'SessionEnd hook executed on exit'}))\"";
|
||||
|
||||
await rig.setup('should fire SessionEnd hook on graceful exit', {
|
||||
rig.setup('should fire SessionEnd hook on graceful exit', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.session-startup.responses',
|
||||
@@ -1412,7 +1593,7 @@ console.log(JSON.stringify({
|
||||
|
||||
describe('Hook Disabling', () => {
|
||||
it('should not execute hooks disabled in settings file', async () => {
|
||||
await rig.setup('should not execute hooks disabled in settings file', {
|
||||
rig.setup('should not execute hooks disabled in settings file', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.disabled-via-settings.responses',
|
||||
@@ -1432,7 +1613,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
writeFileSync(enabledPath, enabledHookScript);
|
||||
writeFileSync(disabledPath, disabledHookScript);
|
||||
|
||||
await rig.setup('should not execute hooks disabled in settings file', {
|
||||
rig.setup('should not execute hooks disabled in settings file', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
@@ -1487,15 +1668,12 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
});
|
||||
|
||||
it('should respect disabled hooks across multiple operations', async () => {
|
||||
await rig.setup(
|
||||
'should respect disabled hooks across multiple operations',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.disabled-via-command.responses',
|
||||
),
|
||||
},
|
||||
);
|
||||
rig.setup('should respect disabled hooks across multiple operations', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.disabled-via-command.responses',
|
||||
),
|
||||
});
|
||||
|
||||
// Create two hook scripts - one that will be disabled, one that won't
|
||||
const activeHookScript = `const fs = require('fs');
|
||||
@@ -1510,33 +1688,30 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
writeFileSync(activePath, activeHookScript);
|
||||
writeFileSync(disabledPath, disabledHookScript);
|
||||
|
||||
await rig.setup(
|
||||
'should respect disabled hooks across multiple operations',
|
||||
{
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${activePath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${disabledPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
disabled: [`node "${disabledPath}"`], // Disable the second hook
|
||||
},
|
||||
rig.setup('should respect disabled hooks across multiple operations', {
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${activePath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${disabledPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
disabled: [`node "${disabledPath}"`], // Disable the second hook
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// First run - only active hook should execute
|
||||
const result1 = await rig.run({
|
||||
@@ -1587,9 +1762,7 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
describe('BeforeTool Hooks - Input Override', () => {
|
||||
it('should override tool input parameters via BeforeTool hook', async () => {
|
||||
// 1. First setup to get the test directory and prepare the hook script
|
||||
await rig.setup(
|
||||
'should override tool input parameters via BeforeTool hook',
|
||||
);
|
||||
rig.setup('should override tool input parameters via BeforeTool hook');
|
||||
|
||||
// Create a hook script that overrides the tool input
|
||||
const hookOutput = {
|
||||
@@ -1616,32 +1789,29 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
const commandPath = scriptPath.replace(/\\/g, '/');
|
||||
|
||||
// 2. Full setup with settings and fake responses
|
||||
await rig.setup(
|
||||
'should override tool input parameters via BeforeTool hook',
|
||||
{
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.input-modification.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${commandPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
rig.setup('should override tool input parameters via BeforeTool hook', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.input-modification.responses',
|
||||
),
|
||||
settings: {
|
||||
hooks: {
|
||||
enabled: true,
|
||||
BeforeTool: [
|
||||
{
|
||||
matcher: 'write_file',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${commandPath}"`,
|
||||
timeout: 5000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Run the agent. The fake response will attempt to call write_file with
|
||||
// file_path="original.txt" and content="original content"
|
||||
@@ -1698,12 +1868,12 @@ console.log(JSON.stringify({decision: "block", systemMessage: "Disabled hook sho
|
||||
hookOutput,
|
||||
)}));`;
|
||||
|
||||
await rig.setup('should stop agent execution via BeforeTool hook');
|
||||
rig.setup('should stop agent execution via BeforeTool hook');
|
||||
const scriptPath = join(rig.testDir!, 'before_tool_stop_hook.js');
|
||||
writeFileSync(scriptPath, hookScript);
|
||||
const commandPath = scriptPath.replace(/\\/g, '/');
|
||||
|
||||
await rig.setup('should stop agent execution via BeforeTool hook', {
|
||||
rig.setup('should stop agent execution via BeforeTool hook', {
|
||||
fakeResponsesPath: join(
|
||||
import.meta.dirname,
|
||||
'hooks-system.before-tool-stop.responses',
|
||||
|
||||
@@ -575,7 +575,10 @@ export class Task {
|
||||
EDIT_TOOL_NAMES.has(request.name),
|
||||
);
|
||||
|
||||
if (restorableToolCalls.length > 0) {
|
||||
if (
|
||||
restorableToolCalls.length > 0 &&
|
||||
this.config.getCheckpointingEnabled()
|
||||
) {
|
||||
const gitService = await this.config.getGitService();
|
||||
if (gitService) {
|
||||
const { checkpointsToWrite, toolCallToCheckpointMap, errors } =
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { loadConfig } from './config.js';
|
||||
import type { ExtensionLoader } from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
|
||||
const {
|
||||
mockLoadServerHierarchicalMemory,
|
||||
mockConfigConstructor,
|
||||
mockVerifyGitAvailability,
|
||||
} = vi.hoisted(() => ({
|
||||
mockLoadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
mockConfigConstructor: vi.fn(),
|
||||
mockVerifyGitAvailability: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => ({
|
||||
Config: class MockConfig {
|
||||
constructor(params: unknown) {
|
||||
mockConfigConstructor(params);
|
||||
}
|
||||
initialize = vi.fn();
|
||||
refreshAuth = vi.fn();
|
||||
},
|
||||
loadServerHierarchicalMemory: mockLoadServerHierarchicalMemory,
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
FileDiscoveryService: vi.fn(),
|
||||
ApprovalMode: { DEFAULT: 'default', YOLO: 'yolo' },
|
||||
AuthType: {
|
||||
LOGIN_WITH_GOOGLE: 'login_with_google',
|
||||
USE_GEMINI: 'use_gemini',
|
||||
},
|
||||
GEMINI_DIR: '.gemini',
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL: 'models/embedding-001',
|
||||
DEFAULT_GEMINI_MODEL: 'models/gemini-1.5-flash',
|
||||
PREVIEW_GEMINI_MODEL: 'models/gemini-1.5-pro-latest',
|
||||
homedir: () => '/tmp',
|
||||
GitService: {
|
||||
verifyGitAvailability: mockVerifyGitAvailability,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('loadConfig', () => {
|
||||
const mockSettings = {
|
||||
checkpointing: { enabled: true },
|
||||
};
|
||||
const mockExtensionLoader = {
|
||||
start: vi.fn(),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
} as unknown as ExtensionLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env['GEMINI_API_KEY'] = 'test-key';
|
||||
// Reset the mock return value just in case
|
||||
mockLoadServerHierarchicalMemory.mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['CHECKPOINTING'];
|
||||
});
|
||||
|
||||
it('should disable checkpointing if git is not installed', async () => {
|
||||
mockVerifyGitAvailability.mockResolvedValue(false);
|
||||
|
||||
await loadConfig(
|
||||
mockSettings as unknown as Settings,
|
||||
mockExtensionLoader,
|
||||
'test-task',
|
||||
);
|
||||
|
||||
expect(mockConfigConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkpointing: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable checkpointing if git is installed', async () => {
|
||||
mockVerifyGitAvailability.mockResolvedValue(true);
|
||||
|
||||
await loadConfig(
|
||||
mockSettings as unknown as Settings,
|
||||
mockExtensionLoader,
|
||||
'test-task',
|
||||
);
|
||||
|
||||
expect(mockConfigConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
checkpointing: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
homedir,
|
||||
GitService,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
@@ -41,6 +42,19 @@ export async function loadConfig(
|
||||
settings.folderTrust === true ||
|
||||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
|
||||
|
||||
let checkpointing = process.env['CHECKPOINTING']
|
||||
? process.env['CHECKPOINTING'] === 'true'
|
||||
: settings.checkpointing?.enabled;
|
||||
|
||||
if (checkpointing) {
|
||||
if (!(await GitService.verifyGitAvailability())) {
|
||||
logger.warn(
|
||||
'[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.',
|
||||
);
|
||||
checkpointing = false;
|
||||
}
|
||||
}
|
||||
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: taskId,
|
||||
model: settings.general?.previewFeatures
|
||||
@@ -79,9 +93,7 @@ export async function loadConfig(
|
||||
folderTrust,
|
||||
trustedFolder: true,
|
||||
extensionLoader,
|
||||
checkpointing: process.env['CHECKPOINTING']
|
||||
? process.env['CHECKPOINTING'] === 'true'
|
||||
: settings.checkpointing?.enabled,
|
||||
checkpointing,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
interactive: true,
|
||||
enableInteractiveShell: true,
|
||||
|
||||
@@ -11,6 +11,30 @@ import { FatalError, writeToStderr } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './src/utils/cleanup.js';
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
// Suppress known race condition error in node-pty on Windows
|
||||
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
error instanceof Error &&
|
||||
error.message === 'Cannot resize a pty that has already exited'
|
||||
) {
|
||||
// This error happens on Windows with node-pty when resizing a pty that has just exited.
|
||||
// It is a race condition in node-pty that we cannot prevent, so we silence it.
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, we rely on the default behavior, but since we attached a listener,
|
||||
// we must manually replicate it.
|
||||
if (error instanceof Error) {
|
||||
writeToStderr(error.stack + '\n');
|
||||
} else {
|
||||
writeToStderr(String(error) + '\n');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main().catch(async (error) => {
|
||||
await runExitCleanup();
|
||||
|
||||
|
||||
@@ -54,15 +54,33 @@ describe('extensionsCommand', () => {
|
||||
extensionsCommand.builder(mockYargs);
|
||||
|
||||
expect(mockYargs.middleware).toHaveBeenCalled();
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'install' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'uninstall' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'update' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'disable' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'enable' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'link' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'new' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'validate' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'install' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'uninstall' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'list' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'update' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'disable' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'enable' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'link' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'new' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'validate' }),
|
||||
);
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
|
||||
expect(mockYargs.version).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { newCommand } from './extensions/new.js';
|
||||
import { validateCommand } from './extensions/validate.js';
|
||||
import { configureCommand } from './extensions/configure.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
export const extensionsCommand: CommandModule = {
|
||||
command: 'extensions <command>',
|
||||
@@ -24,16 +25,16 @@ export const extensionsCommand: CommandModule = {
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware(() => initializeOutputListenersAndFlush())
|
||||
.command(installCommand)
|
||||
.command(uninstallCommand)
|
||||
.command(listCommand)
|
||||
.command(updateCommand)
|
||||
.command(disableCommand)
|
||||
.command(enableCommand)
|
||||
.command(linkCommand)
|
||||
.command(newCommand)
|
||||
.command(validateCommand)
|
||||
.command(configureCommand)
|
||||
.command(defer(installCommand, 'extensions'))
|
||||
.command(defer(uninstallCommand, 'extensions'))
|
||||
.command(defer(listCommand, 'extensions'))
|
||||
.command(defer(updateCommand, 'extensions'))
|
||||
.command(defer(disableCommand, 'extensions'))
|
||||
.command(defer(enableCommand, 'extensions'))
|
||||
.command(defer(linkCommand, 'extensions'))
|
||||
.command(defer(newCommand, 'extensions'))
|
||||
.command(defer(validateCommand, 'extensions'))
|
||||
.command(defer(configureCommand, 'extensions'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "hooks-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node ${extensionPath}/scripts/on-start.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
console.log(
|
||||
'Session Started! This is running from a script in the hooks-example extension.',
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
# MCP Server Example
|
||||
|
||||
This is a basic example of an MCP (Model Context Protocol) server used as a
|
||||
Gemini CLI extension. It demonstrates how to expose tools and prompts to the
|
||||
Gemini CLI.
|
||||
|
||||
## Description
|
||||
|
||||
The contents of this directory are a valid MCP server implementation using the
|
||||
`@modelcontextprotocol/sdk`. It exposes:
|
||||
|
||||
- A tool `fetch_posts` that mock-fetches posts.
|
||||
- A prompt `poem-writer`.
|
||||
|
||||
## Structure
|
||||
|
||||
- `example.js`: The main server entry point.
|
||||
- `gemini-extension.json`: The configuration file that tells Gemini CLI how to
|
||||
use this extension.
|
||||
- `package.json`: Helper for dependencies.
|
||||
|
||||
## How to Use
|
||||
|
||||
1. Navigate to this directory:
|
||||
|
||||
```bash
|
||||
cd packages/cli/src/commands/extensions/examples/mcp-server
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
This example is typically used by `gemini extensions new`.
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Mock the MCP server and transport
|
||||
const mockRegisterTool = vi.fn();
|
||||
const mockRegisterPrompt = vi.fn();
|
||||
const mockConnect = vi.fn();
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
|
||||
McpServer: vi.fn().mockImplementation(() => ({
|
||||
registerTool: mockRegisterTool,
|
||||
registerPrompt: mockRegisterPrompt,
|
||||
connect: mockConnect,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
||||
StdioServerTransport: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('MCP Server Example', () => {
|
||||
beforeEach(async () => {
|
||||
// Dynamically import the server setup after mocks are in place
|
||||
await import('./example.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should create an McpServer with the correct name and version', () => {
|
||||
expect(McpServer).toHaveBeenCalledWith({
|
||||
name: 'prompt-server',
|
||||
version: '1.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should register the "fetch_posts" tool', () => {
|
||||
expect(mockRegisterTool).toHaveBeenCalledWith(
|
||||
'fetch_posts',
|
||||
{
|
||||
description: 'Fetches a list of posts from a public API.',
|
||||
inputSchema: z.object({}).shape,
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should register the "poem-writer" prompt', () => {
|
||||
expect(mockRegisterPrompt).toHaveBeenCalledWith(
|
||||
'poem-writer',
|
||||
{
|
||||
title: 'Poem Writer',
|
||||
description: 'Write a nice haiku',
|
||||
argsSchema: expect.any(Object),
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should connect the server to an StdioServerTransport', () => {
|
||||
expect(StdioServerTransport).toHaveBeenCalled();
|
||||
expect(mockConnect).toHaveBeenCalledWith(expect.any(StdioServerTransport));
|
||||
});
|
||||
|
||||
describe('fetch_posts tool implementation', () => {
|
||||
it('should fetch posts and return a formatted response', async () => {
|
||||
const mockPosts = [
|
||||
{ id: 1, title: 'Post 1' },
|
||||
{ id: 2, title: 'Post 2' },
|
||||
];
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue(mockPosts),
|
||||
});
|
||||
|
||||
const toolFn = mockRegisterTool.mock.calls[0][2];
|
||||
const result = await toolFn();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://jsonplaceholder.typicode.com/posts',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ posts: mockPosts }),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poem-writer prompt implementation', () => {
|
||||
it('should generate a prompt with a title', () => {
|
||||
const promptFn = mockRegisterPrompt.mock.calls[0][2];
|
||||
const result = promptFn({ title: 'My Poem' });
|
||||
expect(result).toEqual({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Write a haiku called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a prompt with a title and mood', () => {
|
||||
const promptFn = mockRegisterPrompt.mock.calls[0][2];
|
||||
const result = promptFn({ title: 'My Poem', mood: 'sad' });
|
||||
expect(result).toEqual({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'Write a haiku with the mood sad called My Poem. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables ',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
"mcpServers": {
|
||||
"nodeServer": {
|
||||
"command": "node",
|
||||
"args": ["${extensionPath}${/}dist${/}example.js"],
|
||||
"args": ["${extensionPath}${/}example.js"],
|
||||
"cwd": "${extensionPath}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
"description": "Example MCP Server for Gemini CLI Extension",
|
||||
"type": "module",
|
||||
"main": "example.js",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.4.5",
|
||||
"@types/node": "^20.11.25"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["example.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "skills-example",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: greeter
|
||||
description: A friendly greeter skill
|
||||
---
|
||||
|
||||
You are a friendly greeter. When the user says "hello" or asks for a greeting,
|
||||
you should reply with: "Greetings from the skills-example extension! 👋"
|
||||
@@ -10,6 +10,7 @@ import { addCommand } from './mcp/add.js';
|
||||
import { removeCommand } from './mcp/remove.js';
|
||||
import { listCommand } from './mcp/list.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
export const mcpCommand: CommandModule = {
|
||||
command: 'mcp',
|
||||
@@ -17,9 +18,9 @@ export const mcpCommand: CommandModule = {
|
||||
builder: (yargs: Argv) =>
|
||||
yargs
|
||||
.middleware(() => initializeOutputListenersAndFlush())
|
||||
.command(addCommand)
|
||||
.command(removeCommand)
|
||||
.command(listCommand)
|
||||
.command(defer(addCommand, 'mcp'))
|
||||
.command(defer(removeCommand, 'mcp'))
|
||||
.command(defer(listCommand, 'mcp'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -38,13 +38,19 @@ describe('skillsCommand', () => {
|
||||
skillsCommand.builder(mockYargs);
|
||||
|
||||
expect(mockYargs.middleware).toHaveBeenCalled();
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({
|
||||
command: 'enable <name>',
|
||||
});
|
||||
expect(mockYargs.command).toHaveBeenCalledWith({
|
||||
command: 'disable <name>',
|
||||
});
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ command: 'list' }),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'enable <name>',
|
||||
}),
|
||||
);
|
||||
expect(mockYargs.command).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'disable <name>',
|
||||
}),
|
||||
);
|
||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
|
||||
expect(mockYargs.version).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { disableCommand } from './skills/disable.js';
|
||||
import { installCommand } from './skills/install.js';
|
||||
import { uninstallCommand } from './skills/uninstall.js';
|
||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||
import { defer } from '../deferred.js';
|
||||
|
||||
export const skillsCommand: CommandModule = {
|
||||
command: 'skills <command>',
|
||||
@@ -19,11 +20,11 @@ export const skillsCommand: CommandModule = {
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.middleware(() => initializeOutputListenersAndFlush())
|
||||
.command(listCommand)
|
||||
.command(enableCommand)
|
||||
.command(disableCommand)
|
||||
.command(installCommand)
|
||||
.command(uninstallCommand)
|
||||
.command(defer(listCommand, 'skills'))
|
||||
.command(defer(enableCommand, 'skills'))
|
||||
.command(defer(disableCommand, 'skills'))
|
||||
.command(defer(installCommand, 'skills'))
|
||||
.command(defer(uninstallCommand, 'skills'))
|
||||
.demandCommand(1, 'You need at least one command before continuing.')
|
||||
.version(false),
|
||||
handler: () => {
|
||||
|
||||
@@ -2200,6 +2200,23 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(loadedSettings.merged.admin?.mcp?.enabled).toBe(true);
|
||||
expect(loadedSettings.merged.admin?.extensions?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should set skills based on advancedFeaturesEnabled', () => {
|
||||
const loadedSettings = loadSettings();
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: {
|
||||
advancedFeaturesEnabled: true,
|
||||
},
|
||||
});
|
||||
expect(loadedSettings.merged.admin.skills?.enabled).toBe(true);
|
||||
|
||||
loadedSettings.setRemoteAdminSettings({
|
||||
cliFeatureSetting: {
|
||||
advancedFeaturesEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(loadedSettings.merged.admin.skills?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultsFromSchema', () => {
|
||||
|
||||
@@ -363,6 +363,10 @@ export class LoadedSettings {
|
||||
admin.extensions = { enabled: extensionsSetting.extensionsEnabled };
|
||||
}
|
||||
|
||||
if (cliFeatureSetting?.advancedFeaturesEnabled !== undefined) {
|
||||
admin.skills = { enabled: cliFeatureSetting.advancedFeaturesEnabled };
|
||||
}
|
||||
|
||||
this._remoteAdminSettings = { admin };
|
||||
this._merged = this.computeMergedSettings();
|
||||
}
|
||||
|
||||
@@ -2117,10 +2117,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
type: 'boolean',
|
||||
description: 'Whether to enable the agent.',
|
||||
},
|
||||
disabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to disable the agent.',
|
||||
},
|
||||
},
|
||||
},
|
||||
CustomTheme: {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
runDeferredCommand,
|
||||
defer,
|
||||
setDeferredCommand,
|
||||
type DeferredCommand,
|
||||
} from './deferred.js';
|
||||
import { ExitCodes } from '@google/gemini-cli-core';
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({
|
||||
mockRunExitCleanup: vi.fn(),
|
||||
mockDebugLogger: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: mockDebugLogger,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./utils/cleanup.js', () => ({
|
||||
runExitCleanup: mockRunExitCleanup,
|
||||
}));
|
||||
|
||||
let mockExit: MockInstance;
|
||||
|
||||
describe('deferred', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command
|
||||
});
|
||||
|
||||
const createMockSettings = (adminSettings: unknown = {}): MergedSettings =>
|
||||
({
|
||||
admin: adminSettings,
|
||||
}) as unknown as MergedSettings;
|
||||
|
||||
describe('runDeferredCommand', () => {
|
||||
it('should do nothing if no deferred command is set', async () => {
|
||||
await runDeferredCommand(createMockSettings());
|
||||
expect(mockDebugLogger.log).not.toHaveBeenCalled();
|
||||
expect(mockDebugLogger.error).not.toHaveBeenCalled();
|
||||
expect(mockExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should execute the deferred command if enabled', async () => {
|
||||
const mockHandler = vi.fn();
|
||||
setDeferredCommand({
|
||||
handler: mockHandler,
|
||||
argv: { _: [], $0: 'gemini' } as ArgumentsCamelCase,
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: true } });
|
||||
await runDeferredCommand(settings);
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
|
||||
it('should exit with FATAL_CONFIG_ERROR if MCP is disabled', async () => {
|
||||
setDeferredCommand({
|
||||
handler: vi.fn(),
|
||||
argv: {} as ArgumentsCamelCase,
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ mcp: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
});
|
||||
|
||||
it('should exit with FATAL_CONFIG_ERROR if extensions are disabled', async () => {
|
||||
setDeferredCommand({
|
||||
handler: vi.fn(),
|
||||
argv: {} as ArgumentsCamelCase,
|
||||
commandName: 'extensions',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ extensions: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Extensions are disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
});
|
||||
|
||||
it('should exit with FATAL_CONFIG_ERROR if skills are disabled', async () => {
|
||||
setDeferredCommand({
|
||||
handler: vi.fn(),
|
||||
argv: {} as ArgumentsCamelCase,
|
||||
commandName: 'skills',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({ skills: { enabled: false } });
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: Agent skills are disabled by your admin.',
|
||||
);
|
||||
expect(mockRunExitCleanup).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
});
|
||||
|
||||
it('should execute if admin settings are undefined (default implicit enable)', async () => {
|
||||
const mockHandler = vi.fn();
|
||||
setDeferredCommand({
|
||||
handler: mockHandler,
|
||||
argv: {} as ArgumentsCamelCase,
|
||||
commandName: 'mcp',
|
||||
});
|
||||
|
||||
const settings = createMockSettings({}); // No admin settings
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defer', () => {
|
||||
it('should wrap a command module and defer execution', async () => {
|
||||
const originalHandler = vi.fn();
|
||||
const commandModule: CommandModule = {
|
||||
command: 'test',
|
||||
describe: 'test command',
|
||||
handler: originalHandler,
|
||||
};
|
||||
|
||||
const deferredModule = defer(commandModule);
|
||||
expect(deferredModule.command).toBe(commandModule.command);
|
||||
|
||||
// Execute the wrapper handler
|
||||
const argv = { _: [], $0: 'gemini' } as ArgumentsCamelCase;
|
||||
await deferredModule.handler(argv);
|
||||
|
||||
// Should check that it set the deferred command, but didn't run original handler yet
|
||||
expect(originalHandler).not.toHaveBeenCalled();
|
||||
|
||||
// Now manually run it to verify it captured correctly
|
||||
await runDeferredCommand(createMockSettings());
|
||||
expect(originalHandler).toHaveBeenCalledWith(argv);
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
|
||||
it('should use parentCommandName if provided', async () => {
|
||||
const commandModule: CommandModule = {
|
||||
command: 'subcommand',
|
||||
describe: 'sub command',
|
||||
handler: vi.fn(),
|
||||
};
|
||||
|
||||
const deferredModule = defer(commandModule, 'parent');
|
||||
await deferredModule.handler({} as ArgumentsCamelCase);
|
||||
|
||||
const deferredMcp = defer(commandModule, 'mcp');
|
||||
await deferredMcp.handler({} as ArgumentsCamelCase);
|
||||
|
||||
const mcpSettings = createMockSettings({ mcp: { enabled: false } });
|
||||
await runDeferredCommand(mcpSettings);
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Error: MCP is disabled by your admin.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to unknown if no parentCommandName is provided', async () => {
|
||||
const mockHandler = vi.fn();
|
||||
const commandModule: CommandModule = {
|
||||
command: ['foo', 'infoo'],
|
||||
describe: 'foo command',
|
||||
handler: mockHandler,
|
||||
};
|
||||
|
||||
const deferredModule = defer(commandModule);
|
||||
await deferredModule.handler({} as ArgumentsCamelCase);
|
||||
|
||||
// Verify it runs even if all known commands are disabled,
|
||||
// confirming it didn't capture 'mcp', 'extensions', or 'skills'
|
||||
// and defaulted to 'unknown' (or something else safe).
|
||||
const settings = createMockSettings({
|
||||
mcp: { enabled: false },
|
||||
extensions: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
});
|
||||
|
||||
await runDeferredCommand(settings);
|
||||
|
||||
expect(mockHandler).toHaveBeenCalled();
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { ArgumentsCamelCase, CommandModule } from 'yargs';
|
||||
import { debugLogger, ExitCodes } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './utils/cleanup.js';
|
||||
import type { MergedSettings } from './config/settings.js';
|
||||
import process from 'node:process';
|
||||
|
||||
export interface DeferredCommand {
|
||||
handler: (argv: ArgumentsCamelCase) => void | Promise<void>;
|
||||
argv: ArgumentsCamelCase;
|
||||
commandName: string;
|
||||
}
|
||||
|
||||
let deferredCommand: DeferredCommand | undefined;
|
||||
|
||||
export function setDeferredCommand(command: DeferredCommand) {
|
||||
deferredCommand = command;
|
||||
}
|
||||
|
||||
export async function runDeferredCommand(settings: MergedSettings) {
|
||||
if (!deferredCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adminSettings = settings.admin;
|
||||
const commandName = deferredCommand.commandName;
|
||||
|
||||
if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) {
|
||||
debugLogger.error('Error: MCP is disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
if (
|
||||
commandName === 'extensions' &&
|
||||
adminSettings?.extensions?.enabled === false
|
||||
) {
|
||||
debugLogger.error('Error: Extensions are disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
if (commandName === 'skills' && adminSettings?.skills?.enabled === false) {
|
||||
debugLogger.error('Error: Agent skills are disabled by your admin.');
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
await deferredCommand.handler(deferredCommand.argv);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a command's handler to defer its execution.
|
||||
* It stores the handler and arguments in a singleton `deferredCommand` variable.
|
||||
*/
|
||||
export function defer<T = object, U = object>(
|
||||
commandModule: CommandModule<T, U>,
|
||||
parentCommandName?: string,
|
||||
): CommandModule<T, U> {
|
||||
return {
|
||||
...commandModule,
|
||||
handler: (argv: ArgumentsCamelCase<U>) => {
|
||||
setDeferredCommand({
|
||||
handler: commandModule.handler as (
|
||||
argv: ArgumentsCamelCase,
|
||||
) => void | Promise<void>,
|
||||
argv: argv as unknown as ArgumentsCamelCase,
|
||||
commandName: parentCommandName || 'unknown',
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type ResumedSessionData,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
type AuthType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type InitializationResult } from './core/initializer.js';
|
||||
@@ -1023,6 +1024,210 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate non-interactive auth when selectedType is missing in settings', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const { validateNonInteractiveAuth } = await import(
|
||||
'./validateNonInterActiveAuth.js'
|
||||
);
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: undefined, // Explicitly undefined
|
||||
useExternal: false,
|
||||
},
|
||||
},
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as unknown as CliArgs);
|
||||
|
||||
const refreshAuthSpy = vi.fn();
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => 'test-question',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: () => false,
|
||||
getHookSystem: () => undefined,
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: () => [],
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-001',
|
||||
getApprovalMode: () => 'default',
|
||||
getCoreTools: () => [],
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
refreshAuth: refreshAuthSpy,
|
||||
setTerminalBackground: vi.fn(),
|
||||
getRemoteAdminSettings: () => undefined,
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
} as unknown as Config);
|
||||
|
||||
vi.mock('./utils/readStdin.js', () => ({
|
||||
readStdin: vi.fn().mockResolvedValue(''),
|
||||
}));
|
||||
vi.mock('./nonInteractiveCli.js', () => ({
|
||||
runNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
// We want to verify this is called
|
||||
vi.mocked(validateNonInteractiveAuth).mockResolvedValue(
|
||||
'gemini-api-key' as unknown as AuthType,
|
||||
);
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(validateNonInteractiveAuth).toHaveBeenCalled();
|
||||
expect(refreshAuthSpy).toHaveBeenCalledWith('gemini-api-key');
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should validate non-interactive auth when mcp command is detected, even if interactive is true', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const { validateNonInteractiveAuth } = await import(
|
||||
'./validateNonInterActiveAuth.js'
|
||||
);
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: undefined,
|
||||
useExternal: false,
|
||||
},
|
||||
},
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
_: ['mcp'],
|
||||
} as unknown as CliArgs);
|
||||
|
||||
const refreshAuthSpy = vi.fn();
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue({
|
||||
isInteractive: () => true, // Interactive is true, but 'mcp' command should trigger validation
|
||||
getQuestion: () => 'test-question',
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getPolicyEngine: vi.fn(),
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: () => false,
|
||||
getHookSystem: () => undefined,
|
||||
initialize: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getGeminiMdFileCount: () => 0,
|
||||
getProjectRoot: () => '/',
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: () => [],
|
||||
getModel: () => 'gemini-pro',
|
||||
getEmbeddingModel: () => 'embedding-001',
|
||||
getApprovalMode: () => 'default',
|
||||
getCoreTools: () => [],
|
||||
getTelemetryEnabled: () => false,
|
||||
getTelemetryLogPromptsEnabled: () => false,
|
||||
getFileFilteringRespectGitIgnore: () => true,
|
||||
getOutputFormat: () => 'text',
|
||||
getUsageStatisticsEnabled: () => false,
|
||||
refreshAuth: refreshAuthSpy,
|
||||
setTerminalBackground: vi.fn(),
|
||||
getRemoteAdminSettings: () => undefined,
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
},
|
||||
} as unknown as Config);
|
||||
|
||||
vi.mock('./utils/readStdin.js', () => ({
|
||||
readStdin: vi.fn().mockResolvedValue(''),
|
||||
}));
|
||||
vi.mock('./nonInteractiveCli.js', () => ({
|
||||
runNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mocked(validateNonInteractiveAuth).mockResolvedValue(
|
||||
'gemini-api-key' as unknown as AuthType,
|
||||
);
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(validateNonInteractiveAuth).toHaveBeenCalled();
|
||||
expect(refreshAuthSpy).toHaveBeenCalledWith('gemini-api-key');
|
||||
expect(processExitSpy).toHaveBeenCalledWith(0);
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini.tsx main function exit codes', () => {
|
||||
@@ -1157,6 +1362,7 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
resume: 'invalid-session',
|
||||
_: [],
|
||||
} as unknown as CliArgs);
|
||||
|
||||
vi.mock('./utils/sessionUtils.js', () => ({
|
||||
|
||||
@@ -96,6 +96,7 @@ import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { runDeferredCommand } from './deferred.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
@@ -372,12 +373,12 @@ export async function main() {
|
||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||
// the sandbox because the sandbox will interfere with the Oauth2 web
|
||||
// redirect.
|
||||
if (
|
||||
settings.merged.security.auth.selectedType &&
|
||||
!settings.merged.security.auth.useExternal
|
||||
) {
|
||||
if (!settings.merged.security.auth.useExternal) {
|
||||
try {
|
||||
if (partialConfig.isInteractive()) {
|
||||
if (
|
||||
partialConfig.isInteractive() &&
|
||||
settings.merged.security.auth.selectedType
|
||||
) {
|
||||
const err = validateAuthMethod(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
@@ -388,7 +389,7 @@ export async function main() {
|
||||
await partialConfig.refreshAuth(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
} else {
|
||||
} else if (!partialConfig.isInteractive()) {
|
||||
const authType = await validateNonInteractiveAuth(
|
||||
settings.merged.security.auth.selectedType,
|
||||
settings.merged.security.auth.useExternal,
|
||||
@@ -410,6 +411,9 @@ export async function main() {
|
||||
settings.setRemoteAdminSettings(remoteAdminSettings);
|
||||
}
|
||||
|
||||
// Run deferred command now that we have admin settings.
|
||||
await runDeferredCommand(settings.merged);
|
||||
|
||||
// hop into sandbox if we are outside and sandboxing is enabled
|
||||
if (!process.env['SANDBOX']) {
|
||||
const memoryArgs = settings.merged.advanced.autoConfigureMemory
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('agentsCommand', () => {
|
||||
});
|
||||
// Add agent to disabled overrides so validation passes
|
||||
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
|
||||
disabled: true,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
vi.mocked(enableAgent).mockReturnValue({
|
||||
@@ -264,7 +264,7 @@ describe('agentsCommand', () => {
|
||||
it('should show info message if agent is already disabled', async () => {
|
||||
mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]);
|
||||
mockContext.services.settings.merged.agents.overrides['test-agent'] = {
|
||||
disabled: true,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
const disableCommand = agentsCommand.subCommands?.find(
|
||||
|
||||
@@ -85,10 +85,10 @@ async function enableAction(
|
||||
const allAgents = agentRegistry.getAllAgentNames();
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.keys(overrides).filter(
|
||||
(name) => overrides[name]?.disabled === true,
|
||||
(name) => overrides[name]?.enabled === false,
|
||||
);
|
||||
|
||||
if (allAgents.includes(agentName)) {
|
||||
if (allAgents.includes(agentName) && !disabledAgents.includes(agentName)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -96,7 +96,7 @@ async function enableAction(
|
||||
};
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes(agentName)) {
|
||||
if (!disabledAgents.includes(agentName) && !allAgents.includes(agentName)) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
@@ -155,7 +155,7 @@ async function disableAction(
|
||||
const allAgents = agentRegistry.getAllAgentNames();
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.keys(overrides).filter(
|
||||
(name) => overrides[name]?.disabled === true,
|
||||
(name) => overrides[name]?.enabled === false,
|
||||
);
|
||||
|
||||
if (disabledAgents.includes(agentName)) {
|
||||
@@ -206,7 +206,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) {
|
||||
|
||||
const overrides = settings.merged.agents.overrides;
|
||||
const disabledAgents = Object.entries(overrides)
|
||||
.filter(([_, override]) => override?.disabled === true)
|
||||
.filter(([_, override]) => override?.enabled === false)
|
||||
.map(([name]) => name);
|
||||
|
||||
return disabledAgents.filter((name) => name.startsWith(partialArg));
|
||||
|
||||
@@ -29,8 +29,8 @@ export interface AgentActionResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables an agent by ensuring it is not disabled in any writable scope (User and Workspace).
|
||||
* It sets `agents.overrides.<agentName>.disabled` to `false` if it was found to be `true`.
|
||||
* Enables an agent by ensuring it is enabled in any writable scope (User and Workspace).
|
||||
* It sets `agents.overrides.<agentName>.enabled` to `true`.
|
||||
*/
|
||||
export function enableAgent(
|
||||
settings: LoadedSettings,
|
||||
@@ -45,9 +45,9 @@ export function enableAgent(
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
const agentOverrides =
|
||||
settings.forScope(scope).settings.agents?.overrides;
|
||||
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
|
||||
const isEnabled = agentOverrides?.[agentName]?.enabled === true;
|
||||
|
||||
if (isDisabled) {
|
||||
if (!isEnabled) {
|
||||
foundInDisabledScopes.push({ scope, path: scopePath });
|
||||
} else {
|
||||
alreadyEnabledScopes.push({ scope, path: scopePath });
|
||||
@@ -68,9 +68,8 @@ export function enableAgent(
|
||||
const modifiedScopes: ModifiedScope[] = [];
|
||||
for (const { scope, path } of foundInDisabledScopes) {
|
||||
if (isLoadableSettingScope(scope)) {
|
||||
// Explicitly enable it to override any lower-precedence disables, or just clear the disable.
|
||||
// Setting to false ensures it is enabled.
|
||||
settings.setValue(scope, `agents.overrides.${agentName}.disabled`, false);
|
||||
// Explicitly enable it.
|
||||
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, true);
|
||||
modifiedScopes.push({ scope, path });
|
||||
}
|
||||
}
|
||||
@@ -85,7 +84,7 @@ export function enableAgent(
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables an agent by setting `agents.overrides.<agentName>.disabled` to `true` in the specified scope.
|
||||
* Disables an agent by setting `agents.overrides.<agentName>.enabled` to `false` in the specified scope.
|
||||
*/
|
||||
export function disableAgent(
|
||||
settings: LoadedSettings,
|
||||
@@ -105,9 +104,9 @@ export function disableAgent(
|
||||
|
||||
const scopePath = settings.forScope(scope).path;
|
||||
const agentOverrides = settings.forScope(scope).settings.agents?.overrides;
|
||||
const isDisabled = agentOverrides?.[agentName]?.disabled === true;
|
||||
const isEnabled = agentOverrides?.[agentName]?.enabled !== false;
|
||||
|
||||
if (isDisabled) {
|
||||
if (!isEnabled) {
|
||||
return {
|
||||
status: 'no-op',
|
||||
agentName,
|
||||
@@ -127,7 +126,7 @@ export function disableAgent(
|
||||
if (isLoadableSettingScope(otherScope)) {
|
||||
const otherOverrides =
|
||||
settings.forScope(otherScope).settings.agents?.overrides;
|
||||
if (otherOverrides?.[agentName]?.disabled === true) {
|
||||
if (otherOverrides?.[agentName]?.enabled === false) {
|
||||
alreadyDisabledInOther.push({
|
||||
scope: otherScope,
|
||||
path: settings.forScope(otherScope).path,
|
||||
@@ -135,7 +134,7 @@ export function disableAgent(
|
||||
}
|
||||
}
|
||||
|
||||
settings.setValue(scope, `agents.overrides.${agentName}.disabled`, true);
|
||||
settings.setValue(scope, `agents.overrides.${agentName}.enabled`, false);
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DelegateToAgentTool } from './delegate-to-agent-tool.js';
|
||||
import {
|
||||
DelegateToAgentTool,
|
||||
type DelegateParams,
|
||||
} from './delegate-to-agent-tool.js';
|
||||
import { AgentRegistry } from './registry.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { AgentDefinition } from './types.js';
|
||||
@@ -110,14 +113,17 @@ describe('DelegateToAgentTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate agent_name exists in registry', async () => {
|
||||
// Zod validation happens at build time now (or rather, build validates the schema)
|
||||
// Since we use discriminated union, an invalid agent_name won't match any option.
|
||||
expect(() =>
|
||||
tool.build({
|
||||
agent_name: 'non_existent_agent',
|
||||
}),
|
||||
).toThrow();
|
||||
it('should throw helpful error when agent_name does not exist', async () => {
|
||||
// We allow validation to pass now, checking happens in execute.
|
||||
const invocation = tool.build({
|
||||
agent_name: 'non_existent_agent',
|
||||
} as DelegateParams);
|
||||
|
||||
await expect(() =>
|
||||
invocation.execute(new AbortController().signal),
|
||||
).rejects.toThrow(
|
||||
"Agent 'non_existent_agent' not found. Available agents are: 'test_agent' (A test agent), 'remote_agent' (A remote agent). Please choose a valid agent_name.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate correct arguments', async () => {
|
||||
@@ -138,24 +144,30 @@ describe('DelegateToAgentTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for missing required argument', async () => {
|
||||
// Missing arg1 should fail Zod validation
|
||||
expect(() =>
|
||||
tool.build({
|
||||
agent_name: 'test_agent',
|
||||
arg2: 123,
|
||||
}),
|
||||
).toThrow();
|
||||
it('should throw helpful error for missing required argument', async () => {
|
||||
const invocation = tool.build({
|
||||
agent_name: 'test_agent',
|
||||
arg2: 123,
|
||||
} as DelegateParams);
|
||||
|
||||
await expect(() =>
|
||||
invocation.execute(new AbortController().signal),
|
||||
).rejects.toThrow(
|
||||
"arg1: Required. Expected inputs: 'arg1' (required string), 'arg2' (optional number).",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for invalid argument type', async () => {
|
||||
// arg1 should be string, passing number
|
||||
expect(() =>
|
||||
tool.build({
|
||||
agent_name: 'test_agent',
|
||||
arg1: 123,
|
||||
}),
|
||||
).toThrow();
|
||||
it('should throw helpful error for invalid argument type', async () => {
|
||||
const invocation = tool.build({
|
||||
agent_name: 'test_agent',
|
||||
arg1: 123,
|
||||
} as DelegateParams);
|
||||
|
||||
await expect(() =>
|
||||
invocation.execute(new AbortController().signal),
|
||||
).rejects.toThrow(
|
||||
"arg1: Expected string, received number. Expected inputs: 'arg1' (required string), 'arg2' (optional number).",
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow optional arguments to be omitted', async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { AgentDefinition, AgentInputs } from './types.js';
|
||||
import { SubagentToolWrapper } from './subagent-tool-wrapper.js';
|
||||
|
||||
type DelegateParams = { agent_name: string } & Record<string, unknown>;
|
||||
export type DelegateParams = { agent_name: string } & Record<string, unknown>;
|
||||
|
||||
export class DelegateToAgentTool extends BaseDeclarativeTool<
|
||||
DelegateParams,
|
||||
@@ -126,6 +126,14 @@ export class DelegateToAgentTool extends BaseDeclarativeTool<
|
||||
);
|
||||
}
|
||||
|
||||
override validateToolParams(_params: DelegateParams): string | null {
|
||||
// We override the default schema validation because the generic JSON schema validation
|
||||
// produces poor error messages for discriminated unions (anyOf).
|
||||
// Instead, we perform detailed, agent-specific validation in the `execute` method
|
||||
// to provide rich error messages that help the LLM self-heal.
|
||||
return null;
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: DelegateParams,
|
||||
messageBus: MessageBus,
|
||||
@@ -190,12 +198,79 @@ class DelegateInvocation extends BaseToolInvocation<
|
||||
): Promise<ToolResult> {
|
||||
const definition = this.registry.getDefinition(this.params.agent_name);
|
||||
if (!definition) {
|
||||
const availableAgents = this.registry
|
||||
.getAllDefinitions()
|
||||
.map((def) => `'${def.name}' (${def.description})`)
|
||||
.join(', ');
|
||||
|
||||
throw new Error(
|
||||
`Agent '${this.params.agent_name}' not found in registry.`,
|
||||
`Agent '${this.params.agent_name}' not found. Available agents are: ${availableAgents}. Please choose a valid agent_name.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { agent_name: _agent_name, ...agentArgs } = this.params;
|
||||
|
||||
// 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;
|
||||
|
||||
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(
|
||||
definition,
|
||||
agentArgs as AgentInputs,
|
||||
|
||||
@@ -309,7 +309,7 @@ describe('AgentRegistry', () => {
|
||||
const config = makeMockedConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
generalist: { enabled: true, disabled: true },
|
||||
generalist: { enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -704,7 +704,7 @@ describe('AgentRegistry', () => {
|
||||
const config = makeMockedConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
MockAgent: { disabled: true },
|
||||
MockAgent: { enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -719,7 +719,7 @@ describe('AgentRegistry', () => {
|
||||
const config = makeMockedConfig({
|
||||
agents: {
|
||||
overrides: {
|
||||
RemoteAgent: { disabled: true },
|
||||
RemoteAgent: { enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ export class AgentRegistry {
|
||||
// Only register the agent if it's enabled in the settings and not explicitly disabled via overrides.
|
||||
if (
|
||||
investigatorSettings?.enabled &&
|
||||
!agentsOverrides[CodebaseInvestigatorAgent.name]?.disabled
|
||||
agentsOverrides[CodebaseInvestigatorAgent.name]?.enabled !== false
|
||||
) {
|
||||
let model;
|
||||
const settingsModel = investigatorSettings.model;
|
||||
@@ -200,7 +200,7 @@ export class AgentRegistry {
|
||||
// Register the CLI help agent if it's explicitly enabled and not explicitly disabled via overrides.
|
||||
if (
|
||||
cliHelpSettings.enabled &&
|
||||
!agentsOverrides[CliHelpAgent.name]?.disabled
|
||||
agentsOverrides[CliHelpAgent.name]?.enabled !== false
|
||||
) {
|
||||
this.registerLocalAgent(CliHelpAgent(this.config));
|
||||
}
|
||||
@@ -280,12 +280,8 @@ export class AgentRegistry {
|
||||
const isExperimental = definition.experimental === true;
|
||||
let isEnabled = !isExperimental;
|
||||
|
||||
if (overrides) {
|
||||
if (overrides.disabled !== undefined) {
|
||||
isEnabled = !overrides.disabled;
|
||||
} else if (overrides.enabled !== undefined) {
|
||||
isEnabled = overrides.enabled;
|
||||
}
|
||||
if (overrides && overrides.enabled !== undefined) {
|
||||
isEnabled = overrides.enabled;
|
||||
}
|
||||
|
||||
return isEnabled;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ModelPolicyStateMap,
|
||||
} from './modelPolicy.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
@@ -36,6 +37,13 @@ const DEFAULT_ACTIONS: ModelPolicyActionMap = {
|
||||
unknown: 'prompt',
|
||||
};
|
||||
|
||||
const SILENT_ACTIONS: ModelPolicyActionMap = {
|
||||
terminal: 'silent',
|
||||
transient: 'silent',
|
||||
not_found: 'silent',
|
||||
unknown: 'silent',
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: ModelPolicyStateMap = {
|
||||
terminal: 'terminal',
|
||||
transient: 'terminal',
|
||||
@@ -53,6 +61,22 @@ const PREVIEW_CHAIN: ModelPolicyChain = [
|
||||
definePolicy({ model: PREVIEW_GEMINI_FLASH_MODEL, isLastResort: true }),
|
||||
];
|
||||
|
||||
const FLASH_LITE_CHAIN: ModelPolicyChain = [
|
||||
definePolicy({
|
||||
model: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
actions: SILENT_ACTIONS,
|
||||
}),
|
||||
definePolicy({
|
||||
model: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
actions: SILENT_ACTIONS,
|
||||
}),
|
||||
definePolicy({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
isLastResort: true,
|
||||
actions: SILENT_ACTIONS,
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the default ordered model policy chain for the user.
|
||||
*/
|
||||
@@ -70,6 +94,10 @@ export function createSingleModelChain(model: string): ModelPolicyChain {
|
||||
return [definePolicy({ model, isLastResort: true })];
|
||||
}
|
||||
|
||||
export function getFlashLitePolicyChain(): ModelPolicyChain {
|
||||
return cloneChain(FLASH_LITE_CHAIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a default policy scaffold for models not present in the catalog.
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from './policyHelpers.js';
|
||||
import { createDefaultPolicy } from './policyCatalog.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { DEFAULT_GEMINI_MODEL_AUTO } from '../config/models.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from '../config/models.js';
|
||||
|
||||
const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
({
|
||||
@@ -53,6 +56,26 @@ describe('policyHelpers', () => {
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('uses auto chain when preferred model is auto', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => 'gemini-2.5-pro',
|
||||
});
|
||||
const chain = resolvePolicyChain(config, DEFAULT_GEMINI_MODEL_AUTO);
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-pro');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('uses auto chain when configured model is auto even if preferred is concrete', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
const chain = resolvePolicyChain(config, 'gemini-2.5-pro');
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-pro');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('starts chain from preferredModel when model is "auto"', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
@@ -62,6 +85,28 @@ describe('policyHelpers', () => {
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('returns flash-lite chain when preferred model is flash-lite', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
const chain = resolvePolicyChain(config, DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
expect(chain).toHaveLength(3);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash-lite');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
expect(chain[2]?.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('returns flash-lite chain when configured model is flash-lite', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
expect(chain).toHaveLength(3);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash-lite');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
expect(chain[2]?.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('wraps around the chain when wrapsAround is true', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
createDefaultPolicy,
|
||||
createSingleModelChain,
|
||||
getModelPolicyChain,
|
||||
getFlashLitePolicyChain,
|
||||
} from './policyCatalog.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
isAutoModel,
|
||||
resolveModel,
|
||||
} from '../config/models.js';
|
||||
import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
@@ -38,24 +40,30 @@ export function resolvePolicyChain(
|
||||
): ModelPolicyChain {
|
||||
const modelFromConfig =
|
||||
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
|
||||
const configuredModel = config.getModel();
|
||||
|
||||
let chain;
|
||||
const resolvedModel = resolveModel(modelFromConfig);
|
||||
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel);
|
||||
|
||||
if (
|
||||
config.getModel() === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
config.getModel() === DEFAULT_GEMINI_MODEL_AUTO
|
||||
) {
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (isAutoPreferred || isAutoConfigured) {
|
||||
const previewEnabled =
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled: config.getModel() === PREVIEW_GEMINI_MODEL_AUTO,
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
|
||||
const activeModel = resolveModel(modelFromConfig);
|
||||
|
||||
const activeIndex = chain.findIndex((policy) => policy.model === activeModel);
|
||||
const activeIndex = chain.findIndex(
|
||||
(policy) => policy.model === resolvedModel,
|
||||
);
|
||||
if (activeIndex !== -1) {
|
||||
return wrapsAround
|
||||
? [...chain.slice(activeIndex), ...chain.slice(0, activeIndex)]
|
||||
@@ -64,7 +72,7 @@ export function resolvePolicyChain(
|
||||
|
||||
// If the user specified a model not in the default chain, we assume they want
|
||||
// *only* that model. We do not fallback to the default chain.
|
||||
return [createDefaultPolicy(activeModel, { isLastResort: true })];
|
||||
return [createDefaultPolicy(resolvedModel, { isLastResort: true })];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -302,6 +302,7 @@ const ExtensionsSettingSchema = z.object({
|
||||
|
||||
const CliFeatureSettingSchema = z.object({
|
||||
extensionsSetting: ExtensionsSettingSchema.optional(),
|
||||
advancedFeaturesEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const McpSettingSchema = z.object({
|
||||
|
||||
@@ -64,6 +64,8 @@ import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js';
|
||||
import {
|
||||
RipgrepFallbackEvent,
|
||||
FlashFallbackEvent,
|
||||
ApprovalModeSwitchEvent,
|
||||
ApprovalModeDurationEvent,
|
||||
} from '../telemetry/types.js';
|
||||
import type { FallbackModelHandler } from '../fallback/types.js';
|
||||
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
|
||||
@@ -105,6 +107,10 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { SkillManager, type SkillDefinition } from '../skills/skillManager.js';
|
||||
import { startupProfiler } from '../telemetry/startupProfiler.js';
|
||||
import type { AgentDefinition } from '../agents/types.js';
|
||||
import {
|
||||
logApprovalModeSwitch,
|
||||
logApprovalModeDuration,
|
||||
} from '../telemetry/loggers.js';
|
||||
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
@@ -171,7 +177,6 @@ export interface AgentRunConfig {
|
||||
export interface AgentOverride {
|
||||
modelConfig?: ModelConfig;
|
||||
runConfig?: AgentRunConfig;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -544,6 +549,7 @@ export class Config {
|
||||
private terminalBackground: string | undefined = undefined;
|
||||
private remoteAdminSettings: FetchAdminControlsResponse | undefined;
|
||||
private latestApiRequest: GenerateContentParameters | undefined;
|
||||
private lastModeSwitchTime: number = Date.now();
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
@@ -1278,6 +1284,24 @@ export class Config {
|
||||
return this.userMemory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the MCP context, including memory, tools, and system instructions.
|
||||
*/
|
||||
async refreshMcpContext(): Promise<void> {
|
||||
if (this.experimentalJitContext && this.contextManager) {
|
||||
await this.contextManager.refresh();
|
||||
} else {
|
||||
const { refreshServerHierarchicalMemory } = await import(
|
||||
'../utils/memoryDiscovery.js'
|
||||
);
|
||||
await refreshServerHierarchicalMemory(this);
|
||||
}
|
||||
if (this.geminiClient?.isInitialized()) {
|
||||
await this.geminiClient.setTools();
|
||||
await this.geminiClient.updateSystemInstruction();
|
||||
}
|
||||
}
|
||||
|
||||
setUserMemory(newUserMemory: string): void {
|
||||
this.userMemory = newUserMemory;
|
||||
}
|
||||
@@ -1330,9 +1354,32 @@ export class Config {
|
||||
'Cannot enable privileged approval modes in an untrusted folder.',
|
||||
);
|
||||
}
|
||||
|
||||
const currentMode = this.getApprovalMode();
|
||||
if (currentMode !== mode) {
|
||||
this.logCurrentModeDuration(this.getApprovalMode());
|
||||
logApprovalModeSwitch(
|
||||
this,
|
||||
new ApprovalModeSwitchEvent(currentMode, mode),
|
||||
);
|
||||
this.lastModeSwitchTime = Date.now();
|
||||
}
|
||||
|
||||
this.policyEngine.setApprovalMode(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the duration of the current approval mode.
|
||||
*/
|
||||
logCurrentModeDuration(mode: ApprovalMode): void {
|
||||
const now = Date.now();
|
||||
const duration = now - this.lastModeSwitchTime;
|
||||
logApprovalModeDuration(
|
||||
this,
|
||||
new ApprovalModeDurationEvent(mode, duration),
|
||||
);
|
||||
}
|
||||
|
||||
isYoloModeDisabled(): boolean {
|
||||
return this.disableYoloMode || !this.isTrustedFolder();
|
||||
}
|
||||
@@ -2036,6 +2083,7 @@ export class Config {
|
||||
* Disposes of resources and removes event listeners.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.logCurrentModeDuration(this.getApprovalMode());
|
||||
coreEvents.off(CoreEvent.AgentsRefreshed, this.onAgentsRefreshed);
|
||||
this.agentRegistry?.dispose();
|
||||
this.geminiClient?.dispose();
|
||||
|
||||
@@ -51,6 +51,7 @@ export function resolveModel(
|
||||
case DEFAULT_GEMINI_MODEL_AUTO: {
|
||||
return DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
case GEMINI_MODEL_ALIAS_AUTO:
|
||||
case GEMINI_MODEL_ALIAS_PRO: {
|
||||
return previewFeaturesEnabled
|
||||
? PREVIEW_GEMINI_MODEL
|
||||
|
||||
@@ -303,6 +303,17 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeChat', () => {
|
||||
it('should update telemetry token count when a chat is resumed', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'resumed message' }] },
|
||||
];
|
||||
await client.resumeChat(history);
|
||||
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetChat', () => {
|
||||
it('should create a new chat session, clearing the old history', async () => {
|
||||
// 1. Get the initial chat instance and add some history.
|
||||
|
||||
@@ -269,6 +269,7 @@ export class GeminiClient {
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
): Promise<void> {
|
||||
this.chat = await this.startChat(history, resumedSessionData);
|
||||
this.updateTelemetryTokenCount();
|
||||
}
|
||||
|
||||
getChatRecordingService(): ChatRecordingService | undefined {
|
||||
|
||||
@@ -22,24 +22,12 @@ import { AuthType } from './contentGenerator.js';
|
||||
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
|
||||
import { type RetryOptions } from '../utils/retry.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import { HookSystem } from '../hooks/hookSystem.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { createAvailabilityServiceMock } from '../availability/testUtils.js';
|
||||
import type { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
|
||||
import * as policyHelpers from '../availability/policyHelpers.js';
|
||||
import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils.js';
|
||||
import {
|
||||
fireBeforeModelHook,
|
||||
fireAfterModelHook,
|
||||
fireBeforeToolSelectionHook,
|
||||
} from './geminiChatHookTriggers.js';
|
||||
|
||||
// Mock hook triggers
|
||||
vi.mock('./geminiChatHookTriggers.js', () => ({
|
||||
fireBeforeModelHook: vi.fn(),
|
||||
fireAfterModelHook: vi.fn(),
|
||||
fireBeforeToolSelectionHook: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
import type { HookSystem } from '../hooks/hookSystem.js';
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
const mockFileSystem = new Map<string, string>();
|
||||
@@ -204,9 +192,7 @@ describe('GeminiChat', () => {
|
||||
setSimulate429(false);
|
||||
// Reset history for each test by creating a new instance
|
||||
chat = new GeminiChat(mockConfig);
|
||||
mockConfig.getHookSystem = vi
|
||||
.fn()
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -2283,18 +2269,20 @@ describe('GeminiChat', () => {
|
||||
});
|
||||
|
||||
describe('Hook execution control', () => {
|
||||
let mockHookSystem: HookSystem;
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.getEnableHooks).mockReturnValue(true);
|
||||
// Default to allowing execution
|
||||
vi.mocked(fireBeforeModelHook).mockResolvedValue({ blocked: false });
|
||||
vi.mocked(fireAfterModelHook).mockResolvedValue({
|
||||
response: {} as GenerateContentResponse,
|
||||
});
|
||||
vi.mocked(fireBeforeToolSelectionHook).mockResolvedValue({});
|
||||
|
||||
mockHookSystem = {
|
||||
fireBeforeModelEvent: vi.fn().mockResolvedValue({ blocked: false }),
|
||||
fireAfterModelEvent: vi.fn().mockResolvedValue({ response: {} }),
|
||||
fireBeforeToolSelectionEvent: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as HookSystem;
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(mockHookSystem);
|
||||
});
|
||||
|
||||
it('should yield AGENT_EXECUTION_STOPPED when BeforeModel hook stops execution', async () => {
|
||||
vi.mocked(fireBeforeModelHook).mockResolvedValue({
|
||||
vi.mocked(mockHookSystem.fireBeforeModelEvent).mockResolvedValue({
|
||||
blocked: true,
|
||||
stopped: true,
|
||||
reason: 'stopped by hook',
|
||||
@@ -2324,7 +2312,7 @@ describe('GeminiChat', () => {
|
||||
candidates: [{ content: { parts: [{ text: 'blocked' }] } }],
|
||||
} as GenerateContentResponse;
|
||||
|
||||
vi.mocked(fireBeforeModelHook).mockResolvedValue({
|
||||
vi.mocked(mockHookSystem.fireBeforeModelEvent).mockResolvedValue({
|
||||
blocked: true,
|
||||
reason: 'blocked by hook',
|
||||
syntheticResponse,
|
||||
@@ -2363,7 +2351,7 @@ describe('GeminiChat', () => {
|
||||
})(),
|
||||
);
|
||||
|
||||
vi.mocked(fireAfterModelHook).mockResolvedValue({
|
||||
vi.mocked(mockHookSystem.fireAfterModelEvent).mockResolvedValue({
|
||||
response: {} as GenerateContentResponse,
|
||||
stopped: true,
|
||||
reason: 'stopped by after hook',
|
||||
@@ -2399,7 +2387,7 @@ describe('GeminiChat', () => {
|
||||
})(),
|
||||
);
|
||||
|
||||
vi.mocked(fireAfterModelHook).mockResolvedValue({
|
||||
vi.mocked(mockHookSystem.fireAfterModelEvent).mockResolvedValue({
|
||||
response,
|
||||
blocked: true,
|
||||
reason: 'blocked by after hook',
|
||||
|
||||
@@ -49,11 +49,6 @@ import {
|
||||
applyModelSelection,
|
||||
createAvailabilityContextProvider,
|
||||
} from '../availability/policyHelpers.js';
|
||||
import {
|
||||
fireAfterModelHook,
|
||||
fireBeforeModelHook,
|
||||
fireBeforeToolSelectionHook,
|
||||
} from './geminiChatHookTriggers.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
|
||||
export enum StreamEventType {
|
||||
@@ -507,39 +502,26 @@ export class GeminiChat {
|
||||
? contentsForPreviewModel
|
||||
: requestContents;
|
||||
|
||||
// Fire BeforeModel and BeforeToolSelection hooks if enabled
|
||||
const hooksEnabled = this.config.getEnableHooks();
|
||||
const messageBus = this.config.getMessageBus();
|
||||
if (hooksEnabled && messageBus) {
|
||||
// Fire BeforeModel hook
|
||||
const beforeModelResult = await fireBeforeModelHook(messageBus, {
|
||||
const hookSystem = this.config.getHookSystem();
|
||||
if (hookSystem) {
|
||||
const beforeModelResult = await hookSystem.fireBeforeModelEvent({
|
||||
model: modelToUse,
|
||||
config,
|
||||
contents: contentsToUse,
|
||||
});
|
||||
|
||||
// Check if hook requested to stop execution
|
||||
if (beforeModelResult.stopped) {
|
||||
throw new AgentExecutionStoppedError(
|
||||
beforeModelResult.reason || 'Agent execution stopped by hook',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if hook blocked the model call
|
||||
if (beforeModelResult.blocked) {
|
||||
// Return a synthetic response generator
|
||||
const syntheticResponse = beforeModelResult.syntheticResponse;
|
||||
if (syntheticResponse) {
|
||||
// Ensure synthetic response has a finish reason to prevent InvalidStreamError
|
||||
if (
|
||||
syntheticResponse.candidates &&
|
||||
syntheticResponse.candidates.length > 0
|
||||
) {
|
||||
for (const candidate of syntheticResponse.candidates) {
|
||||
if (!candidate.finishReason) {
|
||||
candidate.finishReason = FinishReason.STOP;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of syntheticResponse?.candidates ?? []) {
|
||||
if (!candidate.finishReason) {
|
||||
candidate.finishReason = FinishReason.STOP;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +531,6 @@ export class GeminiChat {
|
||||
);
|
||||
}
|
||||
|
||||
// Apply modifications from BeforeModel hook
|
||||
if (beforeModelResult.modifiedConfig) {
|
||||
Object.assign(config, beforeModelResult.modifiedConfig);
|
||||
}
|
||||
@@ -560,17 +541,13 @@ export class GeminiChat {
|
||||
contentsToUse = beforeModelResult.modifiedContents as Content[];
|
||||
}
|
||||
|
||||
// Fire BeforeToolSelection hook
|
||||
const toolSelectionResult = await fireBeforeToolSelectionHook(
|
||||
messageBus,
|
||||
{
|
||||
const toolSelectionResult =
|
||||
await hookSystem.fireBeforeToolSelectionEvent({
|
||||
model: modelToUse,
|
||||
config,
|
||||
contents: contentsToUse,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Apply tool configuration modifications
|
||||
if (toolSelectionResult.toolConfig) {
|
||||
config.toolConfig = toolSelectionResult.toolConfig;
|
||||
}
|
||||
@@ -825,12 +802,9 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
// Fire AfterModel hook through MessageBus (only if hooks are enabled)
|
||||
const hooksEnabled = this.config.getEnableHooks();
|
||||
const messageBus = this.config.getMessageBus();
|
||||
if (hooksEnabled && messageBus && originalRequest && chunk) {
|
||||
const hookResult = await fireAfterModelHook(
|
||||
messageBus,
|
||||
const hookSystem = this.config.getHookSystem();
|
||||
if (originalRequest && chunk && hookSystem) {
|
||||
const hookResult = await hookSystem.fireAfterModelEvent(
|
||||
originalRequest,
|
||||
chunk,
|
||||
);
|
||||
@@ -850,7 +824,7 @@ export class GeminiChat {
|
||||
|
||||
yield hookResult.response;
|
||||
} else {
|
||||
yield chunk; // Yield every chunk to the UI immediately.
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,24 @@ import type {
|
||||
SessionEndReason,
|
||||
PreCompressTrigger,
|
||||
DefaultHookOutput,
|
||||
BeforeModelHookOutput,
|
||||
AfterModelHookOutput,
|
||||
BeforeToolSelectionHookOutput,
|
||||
} from './types.js';
|
||||
import type { AggregatedHookResult } from './hookAggregator.js';
|
||||
import type {
|
||||
GenerateContentParameters,
|
||||
GenerateContentResponse,
|
||||
} from '@google/genai';
|
||||
import type {
|
||||
AfterModelHookResult,
|
||||
BeforeModelHookResult,
|
||||
BeforeToolSelectionHookResult,
|
||||
} from '../core/geminiChatHookTriggers.js';
|
||||
/**
|
||||
* Main hook system that coordinates all hook-related functionality
|
||||
*/
|
||||
export class HookSystem {
|
||||
private readonly config: Config;
|
||||
private readonly hookRegistry: HookRegistry;
|
||||
private readonly hookRunner: HookRunner;
|
||||
private readonly hookAggregator: HookAggregator;
|
||||
@@ -33,7 +44,6 @@ export class HookSystem {
|
||||
private readonly hookEventHandler: HookEventHandler;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
const logger: Logger = logs.getLogger(SERVICE_NAME);
|
||||
const messageBus = config.getMessageBus();
|
||||
|
||||
@@ -90,14 +100,10 @@ export class HookSystem {
|
||||
|
||||
/**
|
||||
* Fire hook events directly
|
||||
* Returns undefined if hooks are disabled
|
||||
*/
|
||||
async fireSessionStartEvent(
|
||||
source: SessionStartSource,
|
||||
): Promise<DefaultHookOutput | undefined> {
|
||||
if (!this.config.getEnableHooks()) {
|
||||
return undefined;
|
||||
}
|
||||
const result = await this.hookEventHandler.fireSessionStartEvent(source);
|
||||
return result.finalOutput;
|
||||
}
|
||||
@@ -105,27 +111,18 @@ export class HookSystem {
|
||||
async fireSessionEndEvent(
|
||||
reason: SessionEndReason,
|
||||
): Promise<AggregatedHookResult | undefined> {
|
||||
if (!this.config.getEnableHooks()) {
|
||||
return undefined;
|
||||
}
|
||||
return this.hookEventHandler.fireSessionEndEvent(reason);
|
||||
}
|
||||
|
||||
async firePreCompressEvent(
|
||||
trigger: PreCompressTrigger,
|
||||
): Promise<AggregatedHookResult | undefined> {
|
||||
if (!this.config.getEnableHooks()) {
|
||||
return undefined;
|
||||
}
|
||||
return this.hookEventHandler.firePreCompressEvent(trigger);
|
||||
}
|
||||
|
||||
async fireBeforeAgentEvent(
|
||||
prompt: string,
|
||||
): Promise<DefaultHookOutput | undefined> {
|
||||
if (!this.config.getEnableHooks()) {
|
||||
return undefined;
|
||||
}
|
||||
const result = await this.hookEventHandler.fireBeforeAgentEvent(prompt);
|
||||
return result.finalOutput;
|
||||
}
|
||||
@@ -135,9 +132,6 @@ export class HookSystem {
|
||||
response: string,
|
||||
stopHookActive: boolean = false,
|
||||
): Promise<DefaultHookOutput | undefined> {
|
||||
if (!this.config.getEnableHooks()) {
|
||||
return undefined;
|
||||
}
|
||||
const result = await this.hookEventHandler.fireAfterAgentEvent(
|
||||
prompt,
|
||||
response,
|
||||
@@ -145,4 +139,121 @@ export class HookSystem {
|
||||
);
|
||||
return result.finalOutput;
|
||||
}
|
||||
|
||||
async fireBeforeModelEvent(
|
||||
llmRequest: GenerateContentParameters,
|
||||
): Promise<BeforeModelHookResult> {
|
||||
try {
|
||||
const result =
|
||||
await this.hookEventHandler.fireBeforeModelEvent(llmRequest);
|
||||
const hookOutput = result.finalOutput;
|
||||
|
||||
if (hookOutput?.shouldStopExecution()) {
|
||||
return {
|
||||
blocked: true,
|
||||
stopped: true,
|
||||
reason: hookOutput.getEffectiveReason(),
|
||||
};
|
||||
}
|
||||
|
||||
const blockingError = hookOutput?.getBlockingError();
|
||||
if (blockingError?.blocked) {
|
||||
const beforeModelOutput = hookOutput as BeforeModelHookOutput;
|
||||
const syntheticResponse = beforeModelOutput.getSyntheticResponse();
|
||||
return {
|
||||
blocked: true,
|
||||
reason:
|
||||
hookOutput?.getEffectiveReason() || 'Model call blocked by hook',
|
||||
syntheticResponse,
|
||||
};
|
||||
}
|
||||
|
||||
if (hookOutput) {
|
||||
const beforeModelOutput = hookOutput as BeforeModelHookOutput;
|
||||
const modifiedRequest =
|
||||
beforeModelOutput.applyLLMRequestModifications(llmRequest);
|
||||
return {
|
||||
blocked: false,
|
||||
modifiedConfig: modifiedRequest?.config,
|
||||
modifiedContents: modifiedRequest?.contents,
|
||||
};
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
} catch (error) {
|
||||
debugLogger.debug(`BeforeModelHookEvent failed:`, error);
|
||||
return { blocked: false };
|
||||
}
|
||||
}
|
||||
|
||||
async fireAfterModelEvent(
|
||||
originalRequest: GenerateContentParameters,
|
||||
chunk: GenerateContentResponse,
|
||||
): Promise<AfterModelHookResult> {
|
||||
try {
|
||||
const result = await this.hookEventHandler.fireAfterModelEvent(
|
||||
originalRequest,
|
||||
chunk,
|
||||
);
|
||||
const hookOutput = result.finalOutput;
|
||||
|
||||
if (hookOutput?.shouldStopExecution()) {
|
||||
return {
|
||||
response: chunk,
|
||||
stopped: true,
|
||||
reason: hookOutput.getEffectiveReason(),
|
||||
};
|
||||
}
|
||||
|
||||
const blockingError = hookOutput?.getBlockingError();
|
||||
if (blockingError?.blocked) {
|
||||
return {
|
||||
response: chunk,
|
||||
blocked: true,
|
||||
reason: hookOutput?.getEffectiveReason(),
|
||||
};
|
||||
}
|
||||
|
||||
if (hookOutput) {
|
||||
const afterModelOutput = hookOutput as AfterModelHookOutput;
|
||||
const modifiedResponse = afterModelOutput.getModifiedResponse();
|
||||
if (modifiedResponse) {
|
||||
return { response: modifiedResponse };
|
||||
}
|
||||
}
|
||||
|
||||
return { response: chunk };
|
||||
} catch (error) {
|
||||
debugLogger.debug(`AfterModelHookEvent failed:`, error);
|
||||
return { response: chunk };
|
||||
}
|
||||
}
|
||||
|
||||
async fireBeforeToolSelectionEvent(
|
||||
llmRequest: GenerateContentParameters,
|
||||
): Promise<BeforeToolSelectionHookResult> {
|
||||
try {
|
||||
const result =
|
||||
await this.hookEventHandler.fireBeforeToolSelectionEvent(llmRequest);
|
||||
const hookOutput = result.finalOutput;
|
||||
|
||||
if (hookOutput) {
|
||||
const toolSelectionOutput = hookOutput as BeforeToolSelectionHookOutput;
|
||||
const modifiedConfig = toolSelectionOutput.applyToolConfigModifications(
|
||||
{
|
||||
toolConfig: llmRequest.config?.toolConfig,
|
||||
tools: llmRequest.config?.tools,
|
||||
},
|
||||
);
|
||||
return {
|
||||
toolConfig: modifiedConfig.toolConfig,
|
||||
tools: modifiedConfig.tools,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
} catch (error) {
|
||||
debugLogger.debug(`BeforeToolSelectionEvent failed:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { DefaultStrategy } from './defaultStrategy.js';
|
||||
import type { RoutingContext } from '../routingStrategy.js';
|
||||
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../../config/models.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
} from '../../config/models.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
|
||||
describe('DefaultStrategy', () => {
|
||||
it('should always route to the default Gemini model', async () => {
|
||||
it('should route to the default model when requested model is default auto', async () => {
|
||||
const strategy = new DefaultStrategy();
|
||||
const mockContext = {} as RoutingContext;
|
||||
const mockConfig = {} as Config;
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
const mockClient = {} as BaseLlmClient;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
@@ -29,4 +39,89 @@ describe('DefaultStrategy', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route to the preview model when requested model is preview auto', async () => {
|
||||
const strategy = new DefaultStrategy();
|
||||
const mockContext = {} as RoutingContext;
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
const mockClient = {} as BaseLlmClient;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
|
||||
expect(decision).toEqual({
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'default',
|
||||
latencyMs: 0,
|
||||
reasoning: `Routing to default model: ${PREVIEW_GEMINI_MODEL}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route to the preview model when requested model is auto and previewfeature is on', async () => {
|
||||
const strategy = new DefaultStrategy();
|
||||
const mockContext = {} as RoutingContext;
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue(GEMINI_MODEL_ALIAS_AUTO),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
const mockClient = {} as BaseLlmClient;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
|
||||
expect(decision).toEqual({
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'default',
|
||||
latencyMs: 0,
|
||||
reasoning: `Routing to default model: ${PREVIEW_GEMINI_MODEL}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should route to the default model when requested model is auto and previewfeature is off', async () => {
|
||||
const strategy = new DefaultStrategy();
|
||||
const mockContext = {} as RoutingContext;
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue(GEMINI_MODEL_ALIAS_AUTO),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
const mockClient = {} as BaseLlmClient;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
|
||||
expect(decision).toEqual({
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
metadata: {
|
||||
source: 'default',
|
||||
latencyMs: 0,
|
||||
reasoning: `Routing to default model: ${DEFAULT_GEMINI_MODEL}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// this should not happen, adding the test just in case it happens.
|
||||
it('should route to the same model if it is not an auto mode', async () => {
|
||||
const strategy = new DefaultStrategy();
|
||||
const mockContext = {} as RoutingContext;
|
||||
const mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
const mockClient = {} as BaseLlmClient;
|
||||
|
||||
const decision = await strategy.route(mockContext, mockConfig, mockClient);
|
||||
|
||||
expect(decision).toEqual({
|
||||
model: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
metadata: {
|
||||
source: 'default',
|
||||
latencyMs: 0,
|
||||
reasoning: `Routing to default model: ${PREVIEW_GEMINI_FLASH_MODEL}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,22 +11,26 @@ import type {
|
||||
RoutingDecision,
|
||||
TerminalStrategy,
|
||||
} from '../routingStrategy.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../../config/models.js';
|
||||
import { resolveModel } from '../../config/models.js';
|
||||
|
||||
export class DefaultStrategy implements TerminalStrategy {
|
||||
readonly name = 'default';
|
||||
|
||||
async route(
|
||||
_context: RoutingContext,
|
||||
_config: Config,
|
||||
config: Config,
|
||||
_baseLlmClient: BaseLlmClient,
|
||||
): Promise<RoutingDecision> {
|
||||
const defaultModel = resolveModel(
|
||||
config.getModel(),
|
||||
config.getPreviewFeatures(),
|
||||
);
|
||||
return {
|
||||
model: DEFAULT_GEMINI_MODEL,
|
||||
model: defaultModel,
|
||||
metadata: {
|
||||
source: this.name,
|
||||
latencyMs: 0,
|
||||
reasoning: `Routing to default model: ${DEFAULT_GEMINI_MODEL}`,
|
||||
reasoning: `Routing to default model: ${defaultModel}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,15 +147,13 @@ describe('GitService', () => {
|
||||
|
||||
describe('verifyGitAvailability', () => {
|
||||
it('should resolve true if git --version command succeeds', async () => {
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await expect(service.verifyGitAvailability()).resolves.toBe(true);
|
||||
await expect(GitService.verifyGitAvailability()).resolves.toBe(true);
|
||||
expect(spawnAsync).toHaveBeenCalledWith('git', ['--version']);
|
||||
});
|
||||
|
||||
it('should resolve false if git --version command fails', async () => {
|
||||
(spawnAsync as Mock).mockRejectedValue(new Error('git not found'));
|
||||
const service = new GitService(projectRoot, storage);
|
||||
await expect(service.verifyGitAvailability()).resolves.toBe(false);
|
||||
await expect(GitService.verifyGitAvailability()).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export class GitService {
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const gitAvailable = await this.verifyGitAvailability();
|
||||
const gitAvailable = await GitService.verifyGitAvailability();
|
||||
if (!gitAvailable) {
|
||||
throw new Error(
|
||||
'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.',
|
||||
@@ -42,7 +42,7 @@ export class GitService {
|
||||
}
|
||||
}
|
||||
|
||||
async verifyGitAvailability(): Promise<boolean> {
|
||||
static async verifyGitAvailability(): Promise<boolean> {
|
||||
try {
|
||||
await spawnAsync('git', ['--version']);
|
||||
return true;
|
||||
|
||||
@@ -42,6 +42,8 @@ import type {
|
||||
ExtensionUpdateEvent,
|
||||
LlmLoopCheckEvent,
|
||||
HookCallEvent,
|
||||
ApprovalModeSwitchEvent,
|
||||
ApprovalModeDurationEvent,
|
||||
} from '../types.js';
|
||||
import { EventMetadataKey } from './event-metadata-key.js';
|
||||
import type { Config } from '../../config/config.js';
|
||||
@@ -100,6 +102,8 @@ export enum EventNames {
|
||||
WEB_FETCH_FALLBACK_ATTEMPT = 'web_fetch_fallback_attempt',
|
||||
LLM_LOOP_CHECK = 'llm_loop_check',
|
||||
HOOK_CALL = 'hook_call',
|
||||
APPROVAL_MODE_SWITCH = 'approval_mode_switch',
|
||||
APPROVAL_MODE_DURATION = 'approval_mode_duration',
|
||||
}
|
||||
|
||||
export interface LogResponse {
|
||||
@@ -1467,6 +1471,42 @@ export class ClearcutLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logApprovalModeSwitchEvent(event: ApprovalModeSwitchEvent): void {
|
||||
const data: EventValue[] = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ACTIVE_APPROVAL_MODE,
|
||||
value: event.from_mode,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_APPROVAL_MODE_TO,
|
||||
value: event.to_mode,
|
||||
},
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(EventNames.APPROVAL_MODE_SWITCH, data),
|
||||
);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logApprovalModeDurationEvent(event: ApprovalModeDurationEvent): void {
|
||||
const data: EventValue[] = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ACTIVE_APPROVAL_MODE,
|
||||
value: event.mode,
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_APPROVAL_MODE_DURATION_MS,
|
||||
value: event.duration_ms.toString(),
|
||||
},
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(EventNames.APPROVAL_MODE_DURATION, data),
|
||||
);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds default fields to data, and returns a new data array. This fields
|
||||
* should exist on all log events.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Defines valid event metadata keys for Clearcut logging.
|
||||
export enum EventMetadataKey {
|
||||
// Deleted enums: 24
|
||||
// Next ID: 137
|
||||
// Next ID: 144
|
||||
|
||||
GEMINI_CLI_KEY_UNKNOWN = 0,
|
||||
|
||||
@@ -529,4 +529,17 @@ export enum EventMetadataKey {
|
||||
|
||||
// Logs total RAM in GB of user machine.
|
||||
GEMINI_CLI_RAM_TOTAL_GB = 140,
|
||||
|
||||
// ==========================================================================
|
||||
// Approval Mode Event Keys
|
||||
// ==========================================================================
|
||||
|
||||
// Logs the active approval mode in the session.
|
||||
GEMINI_CLI_ACTIVE_APPROVAL_MODE = 141,
|
||||
|
||||
// Logs the new approval mode.
|
||||
GEMINI_CLI_APPROVAL_MODE_TO = 142,
|
||||
|
||||
// Logs the duration spent in an approval mode in milliseconds.
|
||||
GEMINI_CLI_APPROVAL_MODE_DURATION_MS = 143,
|
||||
}
|
||||
|
||||
@@ -48,9 +48,11 @@ import type {
|
||||
RecoveryAttemptEvent,
|
||||
WebFetchFallbackAttemptEvent,
|
||||
ExtensionUpdateEvent,
|
||||
LlmLoopCheckEvent,
|
||||
ApprovalModeSwitchEvent,
|
||||
ApprovalModeDurationEvent,
|
||||
HookCallEvent,
|
||||
StartupStatsEvent,
|
||||
LlmLoopCheckEvent,
|
||||
} from './types.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -671,6 +673,32 @@ export function logLlmLoopCheck(
|
||||
});
|
||||
}
|
||||
|
||||
export function logApprovalModeSwitch(
|
||||
config: Config,
|
||||
event: ApprovalModeSwitchEvent,
|
||||
) {
|
||||
ClearcutLogger.getInstance(config)?.logApprovalModeSwitchEvent(event);
|
||||
bufferTelemetryEvent(() => {
|
||||
logs.getLogger(SERVICE_NAME).emit({
|
||||
body: event.toLogBody(),
|
||||
attributes: event.toOpenTelemetryAttributes(config),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function logApprovalModeDuration(
|
||||
config: Config,
|
||||
event: ApprovalModeDurationEvent,
|
||||
) {
|
||||
ClearcutLogger.getInstance(config)?.logApprovalModeDurationEvent(event);
|
||||
bufferTelemetryEvent(() => {
|
||||
logs.getLogger(SERVICE_NAME).emit({
|
||||
body: event.toLogBody(),
|
||||
attributes: event.toOpenTelemetryAttributes(config),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function logHookCall(config: Config, event: HookCallEvent): void {
|
||||
ClearcutLogger.getInstance(config)?.logHookCallEvent(event);
|
||||
bufferTelemetryEvent(() => {
|
||||
|
||||
@@ -1845,6 +1845,58 @@ export class WebFetchFallbackAttemptEvent implements BaseTelemetryEvent {
|
||||
}
|
||||
|
||||
export const EVENT_HOOK_CALL = 'gemini_cli.hook_call';
|
||||
export class ApprovalModeSwitchEvent implements BaseTelemetryEvent {
|
||||
eventName = 'approval_mode_switch';
|
||||
from_mode: ApprovalMode;
|
||||
to_mode: ApprovalMode;
|
||||
|
||||
constructor(fromMode: ApprovalMode, toMode: ApprovalMode) {
|
||||
this.from_mode = fromMode;
|
||||
this.to_mode = toMode;
|
||||
}
|
||||
'event.name': string;
|
||||
'event.timestamp': string;
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
event_name: this.eventName,
|
||||
from_mode: this.from_mode,
|
||||
to_mode: this.to_mode,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Approval mode switched from ${this.from_mode} to ${this.to_mode}.`;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApprovalModeDurationEvent implements BaseTelemetryEvent {
|
||||
eventName = 'approval_mode_duration';
|
||||
mode: ApprovalMode;
|
||||
duration_ms: number;
|
||||
|
||||
constructor(mode: ApprovalMode, durationMs: number) {
|
||||
this.mode = mode;
|
||||
this.duration_ms = durationMs;
|
||||
}
|
||||
'event.name': string;
|
||||
'event.timestamp': string;
|
||||
|
||||
toOpenTelemetryAttributes(config: Config): LogAttributes {
|
||||
return {
|
||||
...getCommonAttributes(config),
|
||||
event_name: this.eventName,
|
||||
mode: this.mode,
|
||||
duration_ms: this.duration_ms,
|
||||
};
|
||||
}
|
||||
|
||||
toLogBody(): string {
|
||||
return `Approval mode ${this.mode} was active for ${this.duration_ms}ms.`;
|
||||
}
|
||||
}
|
||||
|
||||
export class HookCallEvent implements BaseTelemetryEvent {
|
||||
'event.name': string;
|
||||
'event.timestamp': string;
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('McpClientManager', () => {
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
isInitialized: vi.fn(),
|
||||
}),
|
||||
refreshMcpContext: vi.fn(),
|
||||
} as unknown as Config);
|
||||
toolRegistry = {} as ToolRegistry;
|
||||
});
|
||||
@@ -69,6 +70,24 @@ describe('McpClientManager', () => {
|
||||
await manager.startConfiguredMcpServers();
|
||||
expect(mockedMcpClient.connect).toHaveBeenCalledOnce();
|
||||
expect(mockedMcpClient.discover).toHaveBeenCalledOnce();
|
||||
expect(mockConfig.refreshMcpContext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should batch context refresh when starting multiple servers', async () => {
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
'server-1': {},
|
||||
'server-2': {},
|
||||
'server-3': {},
|
||||
});
|
||||
const manager = new McpClientManager(toolRegistry, mockConfig);
|
||||
await manager.startConfiguredMcpServers();
|
||||
|
||||
// Each client should be connected/discovered
|
||||
expect(mockedMcpClient.connect).toHaveBeenCalledTimes(3);
|
||||
expect(mockedMcpClient.discover).toHaveBeenCalledTimes(3);
|
||||
|
||||
// But context refresh should happen only once
|
||||
expect(mockConfig.refreshMcpContext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should update global discovery state', async () => {
|
||||
@@ -239,14 +258,16 @@ describe('McpClientManager', () => {
|
||||
const instructions = manager.getMcpInstructions();
|
||||
|
||||
expect(instructions).toContain(
|
||||
"# Instructions for MCP Server 'server-with-instructions'",
|
||||
"The following are instructions provided by the tool server 'server-with-instructions':",
|
||||
);
|
||||
expect(instructions).toContain('---[start of server instructions]---');
|
||||
expect(instructions).toContain(
|
||||
'Instructions for server-with-instructions',
|
||||
);
|
||||
expect(instructions).toContain('---[end of server instructions]---');
|
||||
|
||||
expect(instructions).not.toContain(
|
||||
"# Instructions for MCP Server 'server-without-instructions'",
|
||||
"The following are instructions provided by the tool server 'server-without-instructions':",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ export class McpClientManager {
|
||||
private discoveryPromise: Promise<void> | undefined;
|
||||
private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
private pendingRefreshPromise: Promise<void> | null = null;
|
||||
private readonly blockedMcpServers: Array<{
|
||||
name: string;
|
||||
extensionName: string;
|
||||
@@ -66,10 +67,11 @@ export class McpClientManager {
|
||||
async stopExtension(extension: GeminiCLIExtension) {
|
||||
debugLogger.log(`Unloading extension: ${extension.name}`);
|
||||
await Promise.all(
|
||||
Object.keys(extension.mcpServers ?? {}).map(
|
||||
this.disconnectClient.bind(this),
|
||||
Object.keys(extension.mcpServers ?? {}).map((name) =>
|
||||
this.disconnectClient(name, true),
|
||||
),
|
||||
);
|
||||
await this.cliConfig.refreshMcpContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +91,7 @@ export class McpClientManager {
|
||||
}),
|
||||
),
|
||||
);
|
||||
await this.cliConfig.refreshMcpContext();
|
||||
}
|
||||
|
||||
private isAllowedMcpServer(name: string) {
|
||||
@@ -111,7 +114,7 @@ export class McpClientManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async disconnectClient(name: string) {
|
||||
private async disconnectClient(name: string, skipRefresh = false) {
|
||||
const existing = this.clients.get(name);
|
||||
if (existing) {
|
||||
try {
|
||||
@@ -123,11 +126,10 @@ export class McpClientManager {
|
||||
`Error stopping client '${name}': ${getErrorMessage(error)}`,
|
||||
);
|
||||
} finally {
|
||||
// This is required to update the content generator configuration with the
|
||||
// new tool configuration.
|
||||
const geminiClient = this.cliConfig.getGeminiClient();
|
||||
if (geminiClient.isInitialized()) {
|
||||
await geminiClient.setTools();
|
||||
if (!skipRefresh) {
|
||||
// This is required to update the content generator configuration with the
|
||||
// new tool configuration and system instructions.
|
||||
await this.cliConfig.refreshMcpContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,10 +185,7 @@ export class McpClientManager {
|
||||
this.cliConfig.getDebugMode(),
|
||||
async () => {
|
||||
debugLogger.log('Tools changed, updating Gemini context...');
|
||||
const geminiClient = this.cliConfig.getGeminiClient();
|
||||
if (geminiClient.isInitialized()) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
await this.scheduleMcpContextRefresh();
|
||||
},
|
||||
);
|
||||
if (!existing) {
|
||||
@@ -219,12 +218,6 @@ export class McpClientManager {
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
// This is required to update the content generator configuration with the
|
||||
// new tool configuration.
|
||||
const geminiClient = this.cliConfig.getGeminiClient();
|
||||
if (geminiClient.isInitialized()) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
})().catch(reject);
|
||||
@@ -282,6 +275,7 @@ export class McpClientManager {
|
||||
this.maybeDiscoverMcpServer(name, config),
|
||||
),
|
||||
);
|
||||
await this.cliConfig.refreshMcpContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,6 +297,7 @@ export class McpClientManager {
|
||||
}
|
||||
}),
|
||||
);
|
||||
await this.cliConfig.refreshMcpContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,6 +309,7 @@ export class McpClientManager {
|
||||
throw new Error(`No MCP server registered with the name "${name}"`);
|
||||
}
|
||||
await this.maybeDiscoverMcpServer(name, client.getServerConfig());
|
||||
await this.cliConfig.refreshMcpContext();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -360,13 +356,35 @@ export class McpClientManager {
|
||||
const clientInstructions = client.getInstructions();
|
||||
if (clientInstructions) {
|
||||
instructions.push(
|
||||
`# Instructions for MCP Server '${name}'\n${clientInstructions}`,
|
||||
`The following are instructions provided by the tool server '${name}':\n---[start of server instructions]---\n${clientInstructions}\n---[end of server instructions]---`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return instructions.join('\n\n');
|
||||
}
|
||||
|
||||
private async scheduleMcpContextRefresh(): Promise<void> {
|
||||
if (this.pendingRefreshPromise) {
|
||||
return this.pendingRefreshPromise;
|
||||
}
|
||||
|
||||
this.pendingRefreshPromise = (async () => {
|
||||
// Debounce to coalesce multiple rapid updates
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
try {
|
||||
await this.cliConfig.refreshMcpContext();
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error refreshing MCP context: ${getErrorMessage(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.pendingRefreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.pendingRefreshPromise;
|
||||
}
|
||||
|
||||
getMcpServerCount(): number {
|
||||
return this.clients.size;
|
||||
}
|
||||
|
||||
@@ -1946,10 +1946,6 @@
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to enable the agent."
|
||||
},
|
||||
"disabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to disable the agent."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -197,6 +197,7 @@ export function runSensitiveKeywordLinter() {
|
||||
console.log('\nRunning sensitive keyword linter...');
|
||||
const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g;
|
||||
const ALLOWED_KEYWORDS = new Set([
|
||||
'gemini-3',
|
||||
'gemini-3.0',
|
||||
'gemini-2.5',
|
||||
'gemini-2.0',
|
||||
|
||||
Reference in New Issue
Block a user