From 2e1efaebe4ca0c34dbc2a22984a6569eca15120b Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:51:44 -0500 Subject: [PATCH 01/21] fix(plan): deflake plan mode integration tests (#20477) --- .github/workflows/deflake.yml | 1 - integration-tests/plan-mode.test.ts | 116 +++++++++++++++++++++------- packages/core/src/policy/config.ts | 2 +- packages/test-utils/src/test-rig.ts | 1 + 4 files changed, 88 insertions(+), 32 deletions(-) diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index fbb3e2d8d7..98635dbda7 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -117,7 +117,6 @@ jobs: name: 'Slow E2E - Win' runs-on: 'gemini-cli-windows-16-core' if: "github.repository == 'google-gemini/gemini-cli'" - steps: - name: 'Checkout' uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index f71006a36c..a4af47252c 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, checkModelOutputContent } from './test-helper.js'; +import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js'; describe('Plan Mode', () => { let rig: TestRig; @@ -62,50 +64,98 @@ describe('Plan Mode', () => { }); }); - it.skip('should allow write_file only in the plans directory in plan mode', async () => { - await rig.setup( - 'should allow write_file only in the plans directory in plan mode', - { - settings: { - experimental: { plan: true }, - tools: { - core: ['write_file', 'read_file', 'list_directory'], - allowed: ['write_file'], + it('should allow write_file to the plans directory in plan mode', async () => { + const plansDir = '.gemini/tmp/foo/123/plans'; + const testName = + 'should allow write_file to the plans directory in plan mode'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, }, - general: { defaultApprovalMode: 'plan' }, }, }, - ); - - // We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory. - // Verify that write_file outside of plan directory fails - await rig.run({ - approvalMode: 'plan', - stdin: - 'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory', }); - const toolLogs = rig.readToolLogs(); - const writeLogs = toolLogs.filter( - (l) => l.toolRequest.name === 'write_file', + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); - const planWrite = writeLogs.find( + const run = await rig.runInteractive({ + approvalMode: 'plan', + }); + + await run.type('Create a file called plan.md in the plans directory.'); + await run.type('\r'); + + await rig.expectToolCallSuccess(['write_file'], 30000, (args) => + args.includes('plan.md'), + ); + + const toolLogs = rig.readToolLogs(); + const planWrite = toolLogs.find( (l) => + l.toolRequest.name === 'write_file' && l.toolRequest.args.includes('plans') && l.toolRequest.args.includes('plan.md'), ); + expect(planWrite?.toolRequest.success).toBe(true); + }); - const blockedWrite = writeLogs.find((l) => - l.toolRequest.args.includes('hello.txt'), + it('should deny write_file to non-plans directory in plan mode', async () => { + const plansDir = '.gemini/tmp/foo/123/plans'; + const testName = + 'should deny write_file to non-plans directory in plan mode'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, + }, + }, + }, + }); + + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), ); - // Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't - if (blockedWrite) { - expect(blockedWrite?.toolRequest.success).toBe(false); - } + const run = await rig.runInteractive({ + approvalMode: 'plan', + }); - expect(planWrite?.toolRequest.success).toBe(true); + await run.type('Create a file called hello.txt in the current directory.'); + await run.type('\r'); + + const toolLogs = rig.readToolLogs(); + const writeLog = toolLogs.find( + (l) => + l.toolRequest.name === 'write_file' && + l.toolRequest.args.includes('hello.txt'), + ); + + // In Plan Mode, writes outside the plans directory should be blocked. + // Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail. + if (writeLog) { + expect(writeLog.toolRequest.success).toBe(false); + } }); it('should be able to enter plan mode from default mode', async () => { @@ -119,6 +169,12 @@ describe('Plan Mode', () => { }, }); + // Disable the interactive terminal setup prompt in tests + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), + ); + // Start in default mode and ask to enter plan mode. await rig.run({ approvalMode: 'default', diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 6cdfc199d2..f09db53b70 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -10,10 +10,10 @@ import * as crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { Storage } from '../config/storage.js'; import { + ApprovalMode, type PolicyEngineConfig, PolicyDecision, type PolicyRule, - ApprovalMode, type PolicySettings, type SafetyCheckerRule, } from './types.js'; diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 5026a47d7b..36e0b90f38 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url'; import { env } from 'node:process'; import { setTimeout as sleep } from 'node:timers/promises'; import { DEFAULT_GEMINI_MODEL, GEMINI_DIR } from '@google/gemini-cli-core'; +export { GEMINI_DIR }; import * as pty from '@lydell/node-pty'; import stripAnsi from 'strip-ansi'; import * as os from 'node:os'; From 48412a068eb725e8028419d49689ce2948d9f3b3 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Mon, 2 Mar 2026 11:54:26 -0800 Subject: [PATCH 02/21] Add /unassign support (#20864) Co-authored-by: Jacob Richman --- .../workflows/gemini-self-assign-issue.yml | 42 ++++++++++++++++++- CONTRIBUTING.md | 11 +++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml index c0c79e5c04..454fc4f41b 100644 --- a/.github/workflows/gemini-self-assign-issue.yml +++ b/.github/workflows/gemini-self-assign-issue.yml @@ -25,7 +25,7 @@ jobs: if: |- github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issue_comment' && - contains(github.event.comment.body, '/assign') + (contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign')) runs-on: 'ubuntu-latest' steps: - name: 'Generate GitHub App Token' @@ -38,6 +38,7 @@ jobs: permission-issues: 'write' - name: 'Assign issue to user' + if: "contains(github.event.comment.body, '/assign')" uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' with: github-token: '${{ steps.generate_token.outputs.token }}' @@ -108,3 +109,42 @@ jobs: issue_number: issueNumber, body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).` }); + + - name: 'Unassign issue from user' + if: "contains(github.event.comment.body, '/unassign')" + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const issueNumber = context.issue.number; + const commenter = context.actor; + const owner = context.repo.owner; + const repo = context.repo.repo; + const commentBody = context.payload.comment.body.trim(); + + if (commentBody !== '/unassign') { + return; + } + + const issue = await github.rest.issues.get({ + owner: owner, + repo: repo, + issue_number: issueNumber, + }); + + const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter); + + if (isAssigned) { + await github.rest.issues.removeAssignees({ + owner: owner, + repo: repo, + issue_number: issueNumber, + assignees: [commenter] + }); + await github.rest.issues.createComment({ + owner: owner, + repo: repo, + issue_number: issueNumber, + body: `👋 @${commenter}, you have been unassigned from this issue.` + }); + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28e3c775d3..d442f408f7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,11 +75,14 @@ Replace `` with your pull request number. Authors are encouraged to run this on their own PRs for self-review, and reviewers should use it to augment their manual review process. -### Self assigning issues +### Self-assigning and unassigning issues -To assign an issue to yourself, simply add a comment with the text `/assign`. -The comment must contain only that text and nothing else. This command will -assign the issue to you, provided it is not already assigned. +To assign an issue to yourself, simply add a comment with the text `/assign`. To +unassign yourself from an issue, add a comment with the text `/unassign`. + +The comment must contain only that text and nothing else. These commands will +assign or unassign the issue as requested, provided the conditions are met +(e.g., an issue must be unassigned to be assigned). Please note that you can have a maximum of 3 issues assigned to you at any given time. From 446a4316c463279ce5cada8ab0249989ee34c5f8 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 2 Mar 2026 11:59:48 -0800 Subject: [PATCH 03/21] feat(core): implement HTTP authentication support for A2A remote agents (#20510) Co-authored-by: Adam Weidman --- packages/a2a-server/src/http/app.ts | 49 ++++++- packages/core/src/agents/agentLoader.test.ts | 48 +++++++ packages/core/src/agents/agentLoader.ts | 22 ++- .../core/src/agents/auth-provider/factory.ts | 9 +- .../auth-provider/http-provider.test.ts | 133 ++++++++++++++++++ .../src/agents/auth-provider/http-provider.ts | 88 ++++++++++++ .../core/src/agents/auth-provider/types.ts | 6 + packages/core/src/agents/registry.test.ts | 92 ++++++++++++ packages/core/src/agents/registry.ts | 19 ++- .../core/src/agents/remote-invocation.test.ts | 98 +++++++++++-- packages/core/src/agents/remote-invocation.ts | 28 +++- 11 files changed, 565 insertions(+), 27 deletions(-) create mode 100644 packages/core/src/agents/auth-provider/http-provider.test.ts create mode 100644 packages/core/src/agents/auth-provider/http-provider.ts diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index 161139279b..35ca48949f 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import express from 'express'; +import express, { type Request } from 'express'; import type { AgentCard, Message } from '@a2a-js/sdk'; import { @@ -13,8 +13,9 @@ import { InMemoryTaskStore, DefaultExecutionEventBus, type AgentExecutionEvent, + UnauthenticatedUser, } from '@a2a-js/sdk/server'; -import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components +import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components import { v4 as uuidv4 } from 'uuid'; import { logger } from '../utils/logger.js'; import type { AgentSettings } from '../types.js'; @@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = { pushNotifications: false, stateTransitionHistory: true, }, - securitySchemes: undefined, - security: undefined, + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + basicAuth: { + type: 'http', + scheme: 'basic', + }, + }, + security: [{ bearerAuth: [] }, { basicAuth: [] }], defaultInputModes: ['text'], defaultOutputModes: ['text'], skills: [ @@ -81,6 +91,35 @@ export function updateCoderAgentCardUrl(port: number) { coderAgentCard.url = `http://localhost:${port}/`; } +const customUserBuilder: UserBuilder = async (req: Request) => { + const auth = req.headers['authorization']; + if (auth) { + const scheme = auth.split(' ')[0]; + logger.info( + `[customUserBuilder] Received Authorization header with scheme: ${scheme}`, + ); + } + if (!auth) return new UnauthenticatedUser(); + + // 1. Bearer Auth + if (auth.startsWith('Bearer ')) { + const token = auth.substring(7); + if (token === 'valid-token') { + return { userName: 'bearer-user', isAuthenticated: true }; + } + } + + // 2. Basic Auth + if (auth.startsWith('Basic ')) { + const credentials = Buffer.from(auth.substring(6), 'base64').toString(); + if (credentials === 'admin:password') { + return { userName: 'basic-user', isAuthenticated: true }; + } + } + + return new UnauthenticatedUser(); +}; + async function handleExecuteCommand( req: express.Request, res: express.Response, @@ -204,7 +243,7 @@ export async function createApp() { requestStorage.run({ req }, next); }); - const appBuilder = new A2AExpressApp(requestHandler); + const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder); expressApp = appBuilder.setupRoutes(expressApp, ''); expressApp.use(express.json()); diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index a62c0b02ba..7d264ad299 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -439,6 +439,54 @@ auth: }); }); + it('should parse remote agent with Digest via raw value', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: digest-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: Digest + value: username="admin", response="abc123" +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'digest-agent', + auth: { + type: 'http', + scheme: 'Digest', + value: 'username="admin", response="abc123"', + }, + }); + }); + + it('should parse remote agent with generic raw auth value', async () => { + const filePath = await writeAgentMarkdown(`--- +kind: remote +name: raw-agent +agent_card_url: https://example.com/card +auth: + type: http + scheme: CustomScheme + value: raw-token-value +--- +`); + const result = await parseAgentMarkdown(filePath); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + kind: 'remote', + name: 'raw-agent', + auth: { + type: 'http', + scheme: 'CustomScheme', + value: 'raw-token-value', + }, + }); + }); + it('should throw error for Bearer auth without token', async () => { const filePath = await writeAgentMarkdown(`--- kind: remote diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index 226c133461..6821854ffd 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -50,10 +50,11 @@ interface FrontmatterAuthConfig { key?: string; name?: string; // HTTP - scheme?: 'Bearer' | 'Basic'; + scheme?: string; token?: string; username?: string; password?: string; + value?: string; } interface FrontmatterRemoteAgentDefinition @@ -139,16 +140,21 @@ const apiKeyAuthSchema = z.object({ const httpAuthSchema = z.object({ ...baseAuthFields, type: z.literal('http'), - scheme: z.enum(['Bearer', 'Basic']), + scheme: z.string().min(1), token: z.string().min(1).optional(), username: z.string().min(1).optional(), password: z.string().min(1).optional(), + value: z.string().min(1).optional(), }); const authConfigSchema = z .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchema]) .superRefine((data, ctx) => { if (data.type === 'http') { + if (data.value) { + // Raw mode - only scheme and value are needed + return; + } if (data.scheme === 'Bearer' && !data.token) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -348,6 +354,14 @@ function convertFrontmatterAuthToConfig( 'Internal error: HTTP scheme missing after validation.', ); } + if (frontmatter.value) { + return { + ...base, + type: 'http', + scheme: frontmatter.scheme, + value: frontmatter.value, + }; + } switch (frontmatter.scheme) { case 'Bearer': if (!frontmatter.token) { @@ -375,8 +389,8 @@ function convertFrontmatterAuthToConfig( password: frontmatter.password, }; default: { - const exhaustive: never = frontmatter.scheme; - throw new Error(`Unknown HTTP scheme: ${exhaustive}`); + // Other IANA schemes without a value should not reach here after validation + throw new Error(`Unknown HTTP scheme: ${frontmatter.scheme}`); } } } diff --git a/packages/core/src/agents/auth-provider/factory.ts b/packages/core/src/agents/auth-provider/factory.ts index 9562737345..66b14d0a32 100644 --- a/packages/core/src/agents/auth-provider/factory.ts +++ b/packages/core/src/agents/auth-provider/factory.ts @@ -11,6 +11,7 @@ import type { AuthValidationResult, } from './types.js'; import { ApiKeyAuthProvider } from './api-key-provider.js'; +import { HttpAuthProvider } from './http-provider.js'; export interface CreateAuthProviderOptions { /** Required for OAuth/OIDC token storage. */ @@ -50,9 +51,11 @@ export class A2AAuthProviderFactory { return provider; } - case 'http': - // TODO: Implement - throw new Error('http auth provider not yet implemented'); + case 'http': { + const provider = new HttpAuthProvider(authConfig); + await provider.initialize(); + return provider; + } case 'oauth2': // TODO: Implement diff --git a/packages/core/src/agents/auth-provider/http-provider.test.ts b/packages/core/src/agents/auth-provider/http-provider.test.ts new file mode 100644 index 0000000000..e56dcb839d --- /dev/null +++ b/packages/core/src/agents/auth-provider/http-provider.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HttpAuthProvider } from './http-provider.js'; + +describe('HttpAuthProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Bearer Authentication', () => { + it('should provide Bearer token header', async () => { + const config = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: 'test-token', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer test-token' }); + }); + + it('should resolve token from environment variable', async () => { + process.env['TEST_TOKEN'] = 'env-token'; + const config = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: '$TEST_TOKEN', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'Bearer env-token' }); + delete process.env['TEST_TOKEN']; + }); + }); + + describe('Basic Authentication', () => { + it('should provide Basic auth header', async () => { + const config = { + type: 'http' as const, + scheme: 'Basic' as const, + username: 'user', + password: 'password', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + const expected = Buffer.from('user:password').toString('base64'); + expect(headers).toEqual({ Authorization: `Basic ${expected}` }); + }); + }); + + describe('Generic/Raw Authentication', () => { + it('should provide custom scheme with raw value', async () => { + const config = { + type: 'http' as const, + scheme: 'CustomScheme', + value: 'raw-value-here', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ Authorization: 'CustomScheme raw-value-here' }); + }); + + it('should support Digest via raw value', async () => { + const config = { + type: 'http' as const, + scheme: 'Digest', + value: 'username="foo", response="bar"', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ + Authorization: 'Digest username="foo", response="bar"', + }); + }); + }); + + describe('Retry logic', () => { + it('should re-initialize on 401 for Bearer', async () => { + const config = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: '$DYNAMIC_TOKEN', + }; + process.env['DYNAMIC_TOKEN'] = 'first'; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + process.env['DYNAMIC_TOKEN'] = 'second'; + const mockResponse = { status: 401 } as Response; + const retryHeaders = await provider.shouldRetryWithHeaders( + {}, + mockResponse, + ); + + expect(retryHeaders).toEqual({ Authorization: 'Bearer second' }); + delete process.env['DYNAMIC_TOKEN']; + }); + + it('should stop after max retries', async () => { + const config = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: 'token', + }; + const provider = new HttpAuthProvider(config); + await provider.initialize(); + + const mockResponse = { status: 401 } as Response; + + // MAX_AUTH_RETRIES is 2 + await provider.shouldRetryWithHeaders({}, mockResponse); + await provider.shouldRetryWithHeaders({}, mockResponse); + const third = await provider.shouldRetryWithHeaders({}, mockResponse); + + expect(third).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/http-provider.ts b/packages/core/src/agents/auth-provider/http-provider.ts new file mode 100644 index 0000000000..920424c667 --- /dev/null +++ b/packages/core/src/agents/auth-provider/http-provider.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { HttpAuthConfig } from './types.js'; +import { resolveAuthValue } from './value-resolver.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +/** + * Authentication provider for HTTP authentication schemes. + * Supports Bearer, Basic, and any IANA-registered scheme via raw value. + */ +export class HttpAuthProvider extends BaseA2AAuthProvider { + readonly type = 'http' as const; + + private resolvedToken?: string; + private resolvedUsername?: string; + private resolvedPassword?: string; + private resolvedValue?: string; + + constructor(private readonly config: HttpAuthConfig) { + super(); + } + + override async initialize(): Promise { + const config = this.config; + if ('token' in config) { + this.resolvedToken = await resolveAuthValue(config.token); + } else if ('username' in config) { + this.resolvedUsername = await resolveAuthValue(config.username); + this.resolvedPassword = await resolveAuthValue(config.password); + } else { + // Generic raw value for any other IANA-registered scheme + this.resolvedValue = await resolveAuthValue(config.value); + } + debugLogger.debug( + `[HttpAuthProvider] Initialized with scheme: ${this.config.scheme}`, + ); + } + + override async headers(): Promise { + const config = this.config; + if ('token' in config) { + if (!this.resolvedToken) + throw new Error('HttpAuthProvider not initialized'); + return { Authorization: `Bearer ${this.resolvedToken}` }; + } + + if ('username' in config) { + if (!this.resolvedUsername || !this.resolvedPassword) { + throw new Error('HttpAuthProvider not initialized'); + } + const credentials = Buffer.from( + `${this.resolvedUsername}:${this.resolvedPassword}`, + ).toString('base64'); + return { Authorization: `Basic ${credentials}` }; + } + + // Generic raw value for any other IANA-registered scheme + if (!this.resolvedValue) + throw new Error('HttpAuthProvider not initialized'); + return { Authorization: `${config.scheme} ${this.resolvedValue}` }; + } + + /** + * Re-resolves credentials on auth failure (e.g. rotated tokens via $ENV or !command). + * Respects MAX_AUTH_RETRIES from the base class to prevent infinite loops. + */ + override async shouldRetryWithHeaders( + req: RequestInit, + res: Response, + ): Promise { + if (res.status === 401 || res.status === 403) { + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; + } + debugLogger.debug( + '[HttpAuthProvider] Re-resolving values after auth failure', + ); + await this.initialize(); + } + return super.shouldRetryWithHeaders(req, res); + } +} diff --git a/packages/core/src/agents/auth-provider/types.ts b/packages/core/src/agents/auth-provider/types.ts index 7d41b1b4a9..05342c5d21 100644 --- a/packages/core/src/agents/auth-provider/types.ts +++ b/packages/core/src/agents/auth-provider/types.ts @@ -60,6 +60,12 @@ export type HttpAuthConfig = BaseAuthConfig & { /** For Basic. Supports $ENV_VAR, !command, or literal. */ password: string; } + | { + /** Any IANA-registered scheme (e.g., "Digest", "HOBA", "Custom"). */ + scheme: string; + /** Raw value to be sent as "Authorization: ". Supports $ENV_VAR, !command, or literal. */ + value: string; + } ); /** Client config corresponding to OAuth2SecurityScheme. */ diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index b7977f37bd..7c856e4089 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -30,6 +30,8 @@ import type { ToolRegistry } from '../tools/tool-registry.js'; import { ThinkingLevel } from '@google/genai'; import type { AcknowledgedAgentsService } from './acknowledgedAgents.js'; import { PolicyDecision } from '../policy/types.js'; +import { A2AAuthProviderFactory } from './auth-provider/factory.js'; +import type { A2AAuthProvider } from './auth-provider/types.js'; vi.mock('./agentLoader.js', () => ({ loadAgentsFromDirectory: vi @@ -43,6 +45,12 @@ vi.mock('./a2a-client-manager.js', () => ({ }, })); +vi.mock('./auth-provider/factory.js', () => ({ + A2AAuthProviderFactory: { + create: vi.fn(), + }, +})); + function makeMockedConfig(params?: Partial): Config { const config = makeFakeConfig(params); vi.spyOn(config, 'getToolRegistry').mockReturnValue({ @@ -546,6 +554,90 @@ describe('AgentRegistry', () => { expect(registry.getDefinition('RemoteAgent')).toEqual(remoteAgent); }); + it('should register a remote agent with authentication configuration', async () => { + const mockAuth = { + type: 'http' as const, + scheme: 'Bearer' as const, + token: 'secret-token', + }; + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'RemoteAgentWithAuth', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + auth: mockAuth, + }; + + const mockHandler = { + type: 'http' as const, + headers: vi + .fn() + .mockResolvedValue({ Authorization: 'Bearer secret-token' }), + shouldRetryWithHeaders: vi.fn(), + } as unknown as A2AAuthProvider; + vi.mocked(A2AAuthProviderFactory.create).mockResolvedValue(mockHandler); + + const loadAgentSpy = vi + .fn() + .mockResolvedValue({ name: 'RemoteAgentWithAuth' }); + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: loadAgentSpy, + clearCache: vi.fn(), + } as unknown as A2AClientManager); + + await registry.testRegisterAgent(remoteAgent); + + expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({ + authConfig: mockAuth, + agentName: 'RemoteAgentWithAuth', + }); + expect(loadAgentSpy).toHaveBeenCalledWith( + 'RemoteAgentWithAuth', + 'https://example.com/card', + mockHandler, + ); + expect(registry.getDefinition('RemoteAgentWithAuth')).toEqual( + remoteAgent, + ); + }); + + it('should not register remote agent when auth provider factory returns undefined', async () => { + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'RemoteAgentBadAuth', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + auth: { + type: 'http' as const, + scheme: 'Bearer' as const, + token: 'secret-token', + }, + }; + + vi.mocked(A2AAuthProviderFactory.create).mockResolvedValue(undefined); + const loadAgentSpy = vi.fn(); + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: loadAgentSpy, + clearCache: vi.fn(), + } as unknown as A2AClientManager); + + const warnSpy = vi + .spyOn(debugLogger, 'warn') + .mockImplementation(() => {}); + + await registry.testRegisterAgent(remoteAgent); + + expect(loadAgentSpy).not.toHaveBeenCalled(); + expect(registry.getDefinition('RemoteAgentBadAuth')).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Error loading A2A agent'), + expect.any(Error), + ); + warnSpy.mockRestore(); + }); + it('should log remote agent registration in debug mode', async () => { const debugConfig = makeMockedConfig({ debugMode: true }); const debugRegistry = new TestableAgentRegistry(debugConfig); diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index cf1d95a834..d9de43eb63 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -14,7 +14,8 @@ import { CliHelpAgent } from './cli-help-agent.js'; import { GeneralistAgent } from './generalist-agent.js'; import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js'; import { A2AClientManager } from './a2a-client-manager.js'; -import { ADCHandler } from './remote-invocation.js'; +import { A2AAuthProviderFactory } from './auth-provider/factory.js'; +import type { AuthenticationHandler } from '@a2a-js/sdk/client'; import { type z } from 'zod'; import { debugLogger } from '../utils/debugLogger.js'; import { isAutoModel } from '../config/models.js'; @@ -371,8 +372,20 @@ export class AgentRegistry { // Log remote A2A agent registration for visibility. try { const clientManager = A2AClientManager.getInstance(); - // Use ADCHandler to ensure we can load agents hosted on secure platforms (e.g. Vertex AI) - const authHandler = new ADCHandler(); + let authHandler: AuthenticationHandler | undefined; + if (definition.auth) { + const provider = await A2AAuthProviderFactory.create({ + authConfig: definition.auth, + agentName: definition.name, + }); + if (!provider) { + throw new Error( + `Failed to create auth provider for agent '${definition.name}'`, + ); + } + authHandler = provider; + } + const agentCard = await clientManager.loadAgent( remoteDef.name, remoteDef.agentCardUrl, diff --git a/packages/core/src/agents/remote-invocation.test.ts b/packages/core/src/agents/remote-invocation.test.ts index 9688b61d78..02c655ec27 100644 --- a/packages/core/src/agents/remote-invocation.test.ts +++ b/packages/core/src/agents/remote-invocation.test.ts @@ -20,14 +20,22 @@ import { } from './a2a-client-manager.js'; import type { RemoteAgentDefinition } from './types.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { A2AAuthProviderFactory } from './auth-provider/factory.js'; +import type { A2AAuthProvider } from './auth-provider/types.js'; // Mock A2AClientManager -vi.mock('./a2a-client-manager.js', () => { - const A2AClientManager = { +vi.mock('./a2a-client-manager.js', () => ({ + A2AClientManager: { getInstance: vi.fn(), - }; - return { A2AClientManager }; -}); + }, +})); + +// Mock A2AAuthProviderFactory +vi.mock('./auth-provider/factory.js', () => ({ + A2AAuthProviderFactory: { + create: vi.fn(), + }, +})); describe('RemoteAgentInvocation', () => { const mockDefinition: RemoteAgentDefinition = { @@ -118,7 +126,7 @@ describe('RemoteAgentInvocation', () => { }); describe('Execution Logic', () => { - it('should lazy load the agent with ADCHandler if not present', async () => { + it('should lazy load the agent without auth handler when no auth configured', async () => { mockClientManager.getClient.mockReturnValue(undefined); mockClientManager.sendMessageStream.mockImplementation( async function* () { @@ -143,10 +151,80 @@ describe('RemoteAgentInvocation', () => { expect(mockClientManager.loadAgent).toHaveBeenCalledWith( 'test-agent', 'http://test-agent/card', - expect.objectContaining({ - headers: expect.any(Function), - shouldRetryWithHeaders: expect.any(Function), - }), + undefined, + ); + }); + + it('should use A2AAuthProviderFactory when auth is present in definition', async () => { + const mockAuth = { + type: 'http' as const, + scheme: 'Basic' as const, + username: 'admin', + password: 'password', + }; + const authDefinition: RemoteAgentDefinition = { + ...mockDefinition, + auth: mockAuth, + }; + + const mockHandler = { + type: 'http' as const, + headers: vi.fn().mockResolvedValue({ Authorization: 'Basic dGVzdA==' }), + shouldRetryWithHeaders: vi.fn(), + } as unknown as A2AAuthProvider; + (A2AAuthProviderFactory.create as Mock).mockResolvedValue(mockHandler); + mockClientManager.getClient.mockReturnValue(undefined); + mockClientManager.sendMessageStream.mockImplementation( + async function* () { + yield { + kind: 'message', + messageId: 'msg-1', + role: 'agent', + parts: [{ kind: 'text', text: 'Hello' }], + }; + }, + ); + + const invocation = new RemoteAgentInvocation( + authDefinition, + { query: 'hi' }, + mockMessageBus, + ); + await invocation.execute(new AbortController().signal); + + expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({ + authConfig: mockAuth, + agentName: 'test-agent', + }); + expect(mockClientManager.loadAgent).toHaveBeenCalledWith( + 'test-agent', + 'http://test-agent/card', + mockHandler, + ); + }); + + it('should return error when auth provider factory returns undefined for configured auth', async () => { + const authDefinition: RemoteAgentDefinition = { + ...mockDefinition, + auth: { + type: 'http' as const, + scheme: 'Bearer' as const, + token: 'secret-token', + }, + }; + + (A2AAuthProviderFactory.create as Mock).mockResolvedValue(undefined); + mockClientManager.getClient.mockReturnValue(undefined); + + const invocation = new RemoteAgentInvocation( + authDefinition, + { query: 'hi' }, + mockMessageBus, + ); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error?.message).toContain( + "Failed to create auth provider for agent 'test-agent'", ); }); diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index b76f216f34..dad7f8167d 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -24,6 +24,7 @@ import type { AuthenticationHandler } from '@a2a-js/sdk/client'; import { debugLogger } from '../utils/debugLogger.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { SendMessageResult } from './a2a-client-manager.js'; +import { A2AAuthProviderFactory } from './auth-provider/factory.js'; /** * Authentication handler implementation using Google Application Default Credentials (ADC). @@ -79,7 +80,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation< // TODO: See if we can reuse the singleton from AppContainer or similar, but for now use getInstance directly // as per the current pattern in the codebase. private readonly clientManager = A2AClientManager.getInstance(); - private readonly authHandler = new ADCHandler(); + private authHandler: AuthenticationHandler | undefined; constructor( private readonly definition: RemoteAgentDefinition, @@ -107,6 +108,27 @@ export class RemoteAgentInvocation extends BaseToolInvocation< return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`; } + private async getAuthHandler(): Promise { + if (this.authHandler) { + return this.authHandler; + } + + if (this.definition.auth) { + const provider = await A2AAuthProviderFactory.create({ + authConfig: this.definition.auth, + agentName: this.definition.name, + }); + if (!provider) { + throw new Error( + `Failed to create auth provider for agent '${this.definition.name}'`, + ); + } + this.authHandler = provider; + } + + return this.authHandler; + } + protected override async getConfirmationDetails( _abortSignal: AbortSignal, ): Promise { @@ -138,11 +160,13 @@ export class RemoteAgentInvocation extends BaseToolInvocation< this.taskId = priorState.taskId; } + const authHandler = await this.getAuthHandler(); + if (!this.clientManager.getClient(this.definition.name)) { await this.clientManager.loadAgent( this.definition.name, this.definition.agentCardUrl, - this.authHandler, + authHandler, ); } From 659301ff83459e51e44e76f4e250b508e51f2eae Mon Sep 17 00:00:00 2001 From: Aishanee Shah Date: Mon, 2 Mar 2026 15:11:58 -0500 Subject: [PATCH 04/21] feat(core): centralize read_file limits and update gemini-3 description (#20619) --- .../tools/__snapshots__/read-file.test.ts.snap | 2 ++ .../coreToolsModelSnapshots.test.ts.snap | 2 +- .../definitions/model-family-sets/gemini-3.ts | 7 ++++++- packages/core/src/tools/read-file.test.ts | 8 ++++++++ packages/core/src/utils/constants.ts | 4 ++++ packages/core/src/utils/fileUtils.ts | 17 +++++++++-------- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/core/src/tools/__snapshots__/read-file.test.ts.snap b/packages/core/src/tools/__snapshots__/read-file.test.ts.snap index de36bd639e..36dbcf1572 100644 --- a/packages/core/src/tools/__snapshots__/read-file.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/read-file.test.ts.snap @@ -1,5 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`ReadFileTool > getSchema > should return the Gemini 3 schema when a Gemini 3 modelId is provided 1`] = `"Reads and returns the content of a specified file. To maintain context efficiency, you MUST use 'start_line' and 'end_line' for targeted, surgical reads of specific sections. For your safety, the tool will automatically truncate output exceeding 2000 lines, 2000 characters per line, or 20MB in size; however, triggering these limits is considered token-inefficient. Always retrieve only the minimum content necessary for your next step. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files."`; + exports[`ReadFileTool > getSchema > should return the base schema when no modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; exports[`ReadFileTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index 70cf828d86..e3a80eddd7 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -1197,7 +1197,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: read_file 1`] = ` { - "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", + "description": "Reads and returns the content of a specified file. To maintain context efficiency, you MUST use 'start_line' and 'end_line' for targeted, surgical reads of specific sections. For your safety, the tool will automatically truncate output exceeding 2000 lines, 2000 characters per line, or 20MB in size; however, triggering these limits is considered token-inefficient. Always retrieve only the minimum content necessary for your next step. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.", "name": "read_file", "parametersJsonSchema": { "properties": { diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index d879e4fd43..2c0375baa3 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -79,6 +79,11 @@ import { getExitPlanModeDeclaration, getActivateSkillDeclaration, } from '../dynamic-declaration-helpers.js'; +import { + DEFAULT_MAX_LINES_TEXT_FILE, + MAX_LINE_LENGTH_TEXT_FILE, + MAX_FILE_SIZE_MB, +} from '../../../utils/constants.js'; /** * Gemini 3 tool set. Initially a copy of the default legacy set. @@ -86,7 +91,7 @@ import { export const GEMINI_3_SET: CoreToolSet = { read_file: { name: READ_FILE_TOOL_NAME, - description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, + description: `Reads and returns the content of a specified file. To maintain context efficiency, you MUST use 'start_line' and 'end_line' for targeted, surgical reads of specific sections. For your safety, the tool will automatically truncate output exceeding ${DEFAULT_MAX_LINES_TEXT_FILE} lines, ${MAX_LINE_LENGTH_TEXT_FILE} characters per line, or ${MAX_FILE_SIZE_MB}MB in size; however, triggering these limits is considered token-inefficient. Always retrieve only the minimum content necessary for your next step. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files.`, parametersJsonSchema: { type: 'object', properties: { diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 5457b8337b..8f79bffe17 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -588,5 +588,13 @@ describe('ReadFileTool', () => { expect(schema.name).toBe(ReadFileTool.Name); expect(schema.description).toMatchSnapshot(); }); + + it('should return the Gemini 3 schema when a Gemini 3 modelId is provided', () => { + const modelId = 'gemini-3-pro-preview'; + const schema = tool.getSchema(modelId); + expect(schema.name).toBe(ReadFileTool.Name); + expect(schema.description).toMatchSnapshot(); + expect(schema.description).toContain('surgical reads'); + }); }); }); diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index e11cbb67c1..7c47f77d03 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -6,3 +6,7 @@ export const REFERENCE_CONTENT_START = '--- Content from referenced files ---'; export const REFERENCE_CONTENT_END = '--- End of content ---'; + +export const DEFAULT_MAX_LINES_TEXT_FILE = 2000; +export const MAX_LINE_LENGTH_TEXT_FILE = 2000; +export const MAX_FILE_SIZE_MB = 20; diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 42119c3f18..2497439a63 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -15,6 +15,11 @@ import { ToolErrorType } from '../tools/tool-error.js'; import { BINARY_EXTENSIONS } from './ignorePatterns.js'; import { createRequire as createModuleRequire } from 'node:module'; import { debugLogger } from './debugLogger.js'; +import { + DEFAULT_MAX_LINES_TEXT_FILE, + MAX_LINE_LENGTH_TEXT_FILE, + MAX_FILE_SIZE_MB, +} from './constants.js'; const requireModule = createModuleRequire(import.meta.url); @@ -52,10 +57,6 @@ export async function loadWasmBinary( } } -// Constants for text file processing -export const DEFAULT_MAX_LINES_TEXT_FILE = 2000; -const MAX_LINE_LENGTH_TEXT_FILE = 2000; - // Default values for encoding and separator format export const DEFAULT_ENCODING: BufferEncoding = 'utf-8'; @@ -434,11 +435,11 @@ export async function processSingleFileContent( } const fileSizeInMB = stats.size / (1024 * 1024); - if (fileSizeInMB > 20) { + if (fileSizeInMB > MAX_FILE_SIZE_MB) { return { - llmContent: 'File size exceeds the 20MB limit.', - returnDisplay: 'File size exceeds the 20MB limit.', - error: `File size exceeds the 20MB limit: ${filePath} (${fileSizeInMB.toFixed(2)}MB)`, + llmContent: `File size exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, + returnDisplay: `File size exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, + error: `File size exceeds the ${MAX_FILE_SIZE_MB}MB limit: ${filePath} (${fileSizeInMB.toFixed(2)}MB)`, errorType: ToolErrorType.FILE_TOO_LARGE, }; } From b034dcd41203b4292c4ffab5b25ce37434b7a5d2 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 2 Mar 2026 20:31:02 +0000 Subject: [PATCH 05/21] Do not block CI on evals (#20870) --- .github/workflows/chained_e2e.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 7d13a23938..2e1586bcd4 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -302,7 +302,7 @@ jobs: - name: 'Build project' run: 'npm run build' - - name: 'Run Evals (Required to pass)' + - name: 'Run Evals (ALWAYS_PASSING)' env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' run: 'npm run test:always_passing_evals' @@ -315,7 +315,6 @@ jobs: - 'e2e_linux' - 'e2e_mac' - 'e2e_windows' - - 'evals' - 'merge_queue_skipper' runs-on: 'gemini-cli-ubuntu-16-core' steps: @@ -323,8 +322,7 @@ jobs: run: | if [[ ${NEEDS_E2E_LINUX_RESULT} != 'success' || \ ${NEEDS_E2E_MAC_RESULT} != 'success' || \ - ${NEEDS_E2E_WINDOWS_RESULT} != 'success' || \ - ${NEEDS_EVALS_RESULT} != 'success' ]]; then + ${NEEDS_E2E_WINDOWS_RESULT} != 'success' ]]; then echo "One or more E2E jobs failed." exit 1 fi @@ -333,7 +331,6 @@ jobs: NEEDS_E2E_LINUX_RESULT: '${{ needs.e2e_linux.result }}' NEEDS_E2E_MAC_RESULT: '${{ needs.e2e_mac.result }}' NEEDS_E2E_WINDOWS_RESULT: '${{ needs.e2e_windows.result }}' - NEEDS_EVALS_RESULT: '${{ needs.evals.result }}' set_workflow_status: runs-on: 'gemini-cli-ubuntu-16-core' From 66530e44c8ad91e928e16fa0a34f7bfb8105137f Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Mon, 2 Mar 2026 12:31:52 -0800 Subject: [PATCH 06/21] document node limitation for shift+tab (#20877) --- docs/reference/keyboard-shortcuts.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 4fc28804f7..e5691c43ee 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -152,3 +152,13 @@ available combinations. inline when the cursor is over the placeholder. - `Double-click` on a paste placeholder (alternate buffer mode only): Expand to view full content inline. Double-click again to collapse. + +## Limitations + +- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal): + - `shift+enter` is not supported. + - `shift+tab` + [is not supported](https://github.com/google-gemini/gemini-cli/issues/20314) + on Node 20 and earlier versions of Node 22. +- On macOS's [Terminal](): + - `shift+enter` is not supported. From 3a7a6e154092eb5031a1e8cd2f253823570572e2 Mon Sep 17 00:00:00 2001 From: David Pierce Date: Mon, 2 Mar 2026 20:41:16 +0000 Subject: [PATCH 07/21] Add install as an option when extension is selected. (#20358) --- .../src/ui/commands/extensionsCommand.test.ts | 62 +++++++++++++++++++ .../cli/src/ui/commands/extensionsCommand.ts | 4 +- .../views/ExtensionRegistryView.tsx | 2 +- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index cc862b6c42..c873050490 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -21,6 +21,10 @@ import { ConfigExtensionDialog, type ConfigExtensionDialogProps, } from '../components/ConfigExtensionDialog.js'; +import { + ExtensionRegistryView, + type ExtensionRegistryViewProps, +} from '../components/views/ExtensionRegistryView.js'; import { type CommandContext, type SlashCommand } from './types.js'; import { @@ -39,6 +43,8 @@ import { } from '../../config/extension-manager.js'; import { SettingScope } from '../../config/settings.js'; import { stat } from 'node:fs/promises'; +import { type RegistryExtension } from '../../config/extensionRegistryClient.js'; +import { waitFor } from '../../test-utils/async.js'; vi.mock('../../config/extension-manager.js', async (importOriginal) => { const actual = @@ -167,6 +173,7 @@ describe('extensionsCommand', () => { }, ui: { dispatchExtensionStateUpdate: mockDispatchExtensionState, + removeComponent: vi.fn(), }, }); }); @@ -429,6 +436,61 @@ describe('extensionsCommand', () => { throw new Error('Explore action not found'); } + it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = true; + + const result = await exploreAction(mockContext, ''); + + expect(result).toBeDefined(); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + expect(component.type).toBe(ExtensionRegistryView); + expect(component.props.extensionManager).toBe(mockExtensionLoader); + }); + + it('should handle onSelect and onClose in ExtensionRegistryView', async () => { + mockContext.services.settings.merged.experimental.extensionRegistry = true; + + const result = await exploreAction(mockContext, ''); + if (result?.type !== 'custom_dialog') { + throw new Error('Expected custom_dialog'); + } + + const component = + result.component as ReactElement; + + const extension = { + extensionName: 'test-ext', + url: 'https://github.com/test/ext.git', + } as RegistryExtension; + + vi.mocked(inferInstallMetadata).mockResolvedValue({ + source: extension.url, + type: 'git', + }); + mockInstallExtension.mockResolvedValue({ name: extension.url }); + + // Call onSelect + component.props.onSelect?.(extension); + + await waitFor(() => { + expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: extension.url, + type: 'git', + }); + }); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1); + + // Call onClose + component.props.onClose?.(); + expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2); + }); + it("should add an info message and call 'open' in a non-sandbox environment", async () => { // Ensure no special environment variables that would affect behavior vi.stubEnv('NODE_ENV', ''); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 0a8a8d74e3..842a680a14 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -280,7 +280,9 @@ async function exploreAction( type: 'custom_dialog' as const, component: React.createElement(ExtensionRegistryView, { onSelect: (extension) => { - debugLogger.debug(`Selected extension: ${extension.extensionName}`); + debugLogger.log(`Selected extension: ${extension.extensionName}`); + void installAction(context, extension.url); + context.ui.removeComponent(); }, onClose: () => context.ui.removeComponent(), extensionManager, diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 1f6fba96ea..394eba3a2a 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -24,7 +24,7 @@ import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; import { useUIState } from '../../contexts/UIStateContext.js'; -interface ExtensionRegistryViewProps { +export interface ExtensionRegistryViewProps { onSelect?: (extension: RegistryExtension) => void; onClose?: () => void; extensionManager: ExtensionManager; From aa321b3d8c11afc15390bfc6b5bdccc8a507227b Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:54:05 -0800 Subject: [PATCH 08/21] Update CODEOWNERS for README.md reviewers (#20860) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8377d34af0..201d46a66d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,3 +14,4 @@ # Docs have a dedicated approver group in addition to maintainers /docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs +/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs \ No newline at end of file From ce5a2d0760a7e5e892aebf7ddf679d86674a52c0 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 2 Mar 2026 13:01:49 -0800 Subject: [PATCH 09/21] feat(core): truncate large MCP tool output (#19365) --- .../core/src/scheduler/tool-executor.test.ts | 158 ++++++++++++++++++ packages/core/src/scheduler/tool-executor.ts | 40 +++++ 2 files changed, 198 insertions(+) diff --git a/packages/core/src/scheduler/tool-executor.test.ts b/packages/core/src/scheduler/tool-executor.test.ts index 0d77204f4e..d5f92806f5 100644 --- a/packages/core/src/scheduler/tool-executor.test.ts +++ b/packages/core/src/scheduler/tool-executor.test.ts @@ -16,6 +16,8 @@ import { MockTool } from '../test-utils/mock-tool.js'; import type { ScheduledToolCall } from './types.js'; import { CoreToolCallStatus } from './types.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; +import type { CallableTool } from '@google/genai'; import * as fileUtils from '../utils/fileUtils.js'; import * as coreToolHookTriggers from '../core/coreToolHookTriggers.js'; import { ShellToolInvocation } from '../tools/shell.js'; @@ -312,6 +314,162 @@ describe('ToolExecutor', () => { } }); + it('should truncate large MCP tool output with single text Part', async () => { + // 1. Setup Config for Truncation + vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10); + vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp'); + + const mcpToolName = 'get_big_text'; + const messageBus = createMockMessageBus(); + const mcpTool = new DiscoveredMCPTool( + {} as CallableTool, + 'my-server', + 'get_big_text', + 'A test MCP tool', + {}, + messageBus, + ); + const invocation = mcpTool.build({}); + const longText = 'This is a very long MCP output that should be truncated.'; + + // 2. Mock execution returning Part[] with single text Part + vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({ + llmContent: [{ text: longText }], + returnDisplay: longText, + }); + + const scheduledCall: ScheduledToolCall = { + status: CoreToolCallStatus.Scheduled, + request: { + callId: 'call-mcp-trunc', + name: mcpToolName, + args: { query: 'test' }, + isClientInitiated: false, + prompt_id: 'prompt-mcp-trunc', + }, + tool: mcpTool, + invocation: invocation as unknown as AnyToolInvocation, + startTime: Date.now(), + }; + + // 3. Execute + const result = await executor.execute({ + call: scheduledCall, + signal: new AbortController().signal, + onUpdateToolCall: vi.fn(), + }); + + // 4. Verify Truncation Logic + expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith( + longText, + mcpToolName, + 'call-mcp-trunc', + expect.any(String), + 'test-session-id', + ); + + expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith( + longText, + '/tmp/truncated_output.txt', + 10, + ); + + expect(result.status).toBe(CoreToolCallStatus.Success); + if (result.status === CoreToolCallStatus.Success) { + expect(result.response.outputFile).toBe('/tmp/truncated_output.txt'); + } + }); + + it('should not truncate MCP tool output with multiple Parts', async () => { + vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10); + + const messageBus = createMockMessageBus(); + const mcpTool = new DiscoveredMCPTool( + {} as CallableTool, + 'my-server', + 'get_big_text', + 'A test MCP tool', + {}, + messageBus, + ); + const invocation = mcpTool.build({}); + const longText = 'This is long text that exceeds the threshold.'; + + // Part[] with multiple parts — should NOT be truncated + vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({ + llmContent: [{ text: longText }, { text: 'second part' }], + returnDisplay: longText, + }); + + const scheduledCall: ScheduledToolCall = { + status: CoreToolCallStatus.Scheduled, + request: { + callId: 'call-mcp-multi', + name: 'get_big_text', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-mcp-multi', + }, + tool: mcpTool, + invocation: invocation as unknown as AnyToolInvocation, + startTime: Date.now(), + }; + + const result = await executor.execute({ + call: scheduledCall, + signal: new AbortController().signal, + onUpdateToolCall: vi.fn(), + }); + + // Should NOT have been truncated + expect(fileUtils.saveTruncatedToolOutput).not.toHaveBeenCalled(); + expect(fileUtils.formatTruncatedToolOutput).not.toHaveBeenCalled(); + expect(result.status).toBe(CoreToolCallStatus.Success); + }); + + it('should not truncate MCP tool output when text is below threshold', async () => { + vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10000); + + const messageBus = createMockMessageBus(); + const mcpTool = new DiscoveredMCPTool( + {} as CallableTool, + 'my-server', + 'get_big_text', + 'A test MCP tool', + {}, + messageBus, + ); + const invocation = mcpTool.build({}); + + vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({ + llmContent: [{ text: 'short' }], + returnDisplay: 'short', + }); + + const scheduledCall: ScheduledToolCall = { + status: CoreToolCallStatus.Scheduled, + request: { + callId: 'call-mcp-short', + name: 'get_big_text', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-mcp-short', + }, + tool: mcpTool, + invocation: invocation as unknown as AnyToolInvocation, + startTime: Date.now(), + }; + + const result = await executor.execute({ + call: scheduledCall, + signal: new AbortController().signal, + onUpdateToolCall: vi.fn(), + }); + + expect(fileUtils.saveTruncatedToolOutput).not.toHaveBeenCalled(); + expect(result.status).toBe(CoreToolCallStatus.Success); + }); + it('should report PID updates for shell tools', async () => { // 1. Setup ShellToolInvocation const messageBus = createMockMessageBus(); diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 7903266fe1..d37c49624c 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -18,6 +18,7 @@ import { runInDevTraceSpan, } from '../index.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { ShellToolInvocation } from '../tools/shell.js'; import { executeToolWithHooks } from '../core/coreToolHookTriggers.js'; import { @@ -253,6 +254,45 @@ export class ToolExecutor { }), ); } + } else if ( + Array.isArray(content) && + content.length === 1 && + 'tool' in call && + call.tool instanceof DiscoveredMCPTool + ) { + const firstPart = content[0]; + if (typeof firstPart === 'object' && typeof firstPart.text === 'string') { + const textContent = firstPart.text; + const threshold = this.config.getTruncateToolOutputThreshold(); + + if (threshold > 0 && textContent.length > threshold) { + const originalContentLength = textContent.length; + const { outputFile: savedPath } = await saveTruncatedToolOutput( + textContent, + toolName, + callId, + this.config.storage.getProjectTempDir(), + this.config.getSessionId(), + ); + outputFile = savedPath; + const truncatedText = formatTruncatedToolOutput( + textContent, + outputFile, + threshold, + ); + content[0] = { ...firstPart, text: truncatedText }; + + logToolOutputTruncated( + this.config, + new ToolOutputTruncatedEvent(call.request.prompt_id, { + toolName, + originalContentLength, + truncatedContentLength: truncatedText.length, + threshold, + }), + ); + } + } } const response = convertToFunctionResponse( From 7ca3a33f8bfd544795aa8f5e98ba730351873dd3 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 2 Mar 2026 21:04:31 +0000 Subject: [PATCH 10/21] Subagent activity UX. (#17570) --- packages/a2a-server/src/agent/task.ts | 7 +- .../cli/src/ui/components/MainContent.tsx | 3 +- .../messages/SubagentProgressDisplay.test.tsx | 193 ++++++++++++++++ .../messages/SubagentProgressDisplay.tsx | 151 ++++++++++++ .../components/messages/ToolGroupMessage.tsx | 1 + .../components/messages/ToolResultDisplay.tsx | 9 +- .../SubagentProgressDisplay.test.tsx.snap | 41 ++++ packages/cli/src/ui/hooks/toolMapping.ts | 1 + packages/cli/src/ui/types.ts | 1 + .../agents/browser/browserAgentInvocation.ts | 9 +- .../core/src/agents/local-executor.test.ts | 15 +- packages/core/src/agents/local-executor.ts | 33 ++- .../core/src/agents/local-invocation.test.ts | 105 ++++++--- packages/core/src/agents/local-invocation.ts | 216 ++++++++++++++++-- .../core/src/agents/subagent-tool.test.ts | 10 + packages/core/src/agents/subagent-tool.ts | 4 +- packages/core/src/agents/types.ts | 26 +++ .../core/src/core/coreToolHookTriggers.ts | 5 +- packages/core/src/core/geminiChat.ts | 15 +- packages/core/src/scheduler/tool-executor.ts | 18 +- packages/core/src/scheduler/types.ts | 6 +- packages/core/src/tools/shell.ts | 3 +- packages/core/src/tools/tools.ts | 16 +- packages/core/src/utils/tool-utils.test.ts | 11 + packages/core/src/utils/tool-utils.ts | 16 +- 25 files changed, 827 insertions(+), 88 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 1defbdd36c..c969e601c3 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -27,7 +27,8 @@ import { type ToolCallConfirmationDetails, type Config, type UserTierId, - type AnsiOutput, + type ToolLiveOutput, + isSubagentProgress, EDIT_TOOL_NAMES, processRestorableToolCalls, } from '@google/gemini-cli-core'; @@ -336,11 +337,13 @@ export class Task { private _schedulerOutputUpdate( toolCallId: string, - outputChunk: string | AnsiOutput, + outputChunk: ToolLiveOutput, ): void { let outputAsText: string; if (typeof outputChunk === 'string') { outputAsText = outputChunk; + } else if (isSubagentProgress(outputChunk)) { + outputAsText = JSON.stringify(outputChunk); } else { outputAsText = outputChunk .map((line) => line.map((token) => token.text).join('')) diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index fbcc962663..7386a246e7 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -34,6 +34,7 @@ export const MainContent = () => { const confirmingTool = useConfirmingTool(); const showConfirmationQueue = confirmingTool !== null; + const confirmingToolCallId = confirmingTool?.tool.callId; const scrollableListRef = useRef>(null); @@ -41,7 +42,7 @@ export const MainContent = () => { if (showConfirmationQueue) { scrollableListRef.current?.scrollToEnd(); } - }, [showConfirmationQueue, confirmingTool]); + }, [showConfirmationQueue, confirmingToolCallId]); const { pendingHistoryItems, diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx new file mode 100644 index 0000000000..e8b67301ad --- /dev/null +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.test.tsx @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render, cleanup } from '../../../test-utils/render.js'; +import { SubagentProgressDisplay } from './SubagentProgressDisplay.js'; +import type { SubagentProgress } from '@google/gemini-cli-core'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Text } from 'ink'; + +vi.mock('ink-spinner', () => ({ + default: () => , +})); + +describe('', () => { + afterEach(() => { + vi.restoreAllMocks(); + cleanup(); + }); + + it('renders correctly with description in args', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '1', + type: 'tool_call', + content: 'run_shell_command', + args: '{"command": "echo hello", "description": "Say hello"}', + status: 'running', + }, + ], + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with displayName and description from item', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '1', + type: 'tool_call', + content: 'run_shell_command', + displayName: 'RunShellCommand', + description: 'Executing echo hello', + args: '{"command": "echo hello"}', + status: 'running', + }, + ], + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with command fallback', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '2', + type: 'tool_call', + content: 'run_shell_command', + args: '{"command": "echo hello"}', + status: 'running', + }, + ], + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with file_path', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '3', + type: 'tool_call', + content: 'write_file', + args: '{"file_path": "/tmp/test.txt", "content": "foo"}', + status: 'completed', + }, + ], + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('truncates long args', async () => { + const longDesc = + 'This is a very long description that should definitely be truncated because it exceeds the limit of sixty characters.'; + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '4', + type: 'tool_call', + content: 'run_shell_command', + args: JSON.stringify({ description: longDesc }), + status: 'running', + }, + ], + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders thought bubbles correctly', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '5', + type: 'thought', + content: 'Thinking about life', + status: 'running', + }, + ], + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders cancelled state correctly', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [], + state: 'cancelled', + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders "Request cancelled." with the info icon', async () => { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: 'TestAgent', + recentActivity: [ + { + id: '6', + type: 'thought', + content: 'Request cancelled.', + status: 'error', + }, + ], + }; + + const { lastFrame, waitUntilReady } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx new file mode 100644 index 0000000000..b34a904b3e --- /dev/null +++ b/packages/cli/src/ui/components/messages/SubagentProgressDisplay.tsx @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import Spinner from 'ink-spinner'; +import type { + SubagentProgress, + SubagentActivityItem, +} from '@google/gemini-cli-core'; +import { TOOL_STATUS } from '../../constants.js'; +import { STATUS_INDICATOR_WIDTH } from './ToolShared.js'; + +export interface SubagentProgressDisplayProps { + progress: SubagentProgress; +} + +const formatToolArgs = (args?: string): string => { + if (!args) return ''; + try { + const parsed: unknown = JSON.parse(args); + if (typeof parsed !== 'object' || parsed === null) { + return args; + } + + if ( + 'description' in parsed && + typeof parsed.description === 'string' && + parsed.description + ) { + return parsed.description; + } + if ('command' in parsed && typeof parsed.command === 'string') + return parsed.command; + if ('file_path' in parsed && typeof parsed.file_path === 'string') + return parsed.file_path; + if ('dir_path' in parsed && typeof parsed.dir_path === 'string') + return parsed.dir_path; + if ('query' in parsed && typeof parsed.query === 'string') + return parsed.query; + if ('url' in parsed && typeof parsed.url === 'string') return parsed.url; + if ('target' in parsed && typeof parsed.target === 'string') + return parsed.target; + + return args; + } catch { + return args; + } +}; + +export const SubagentProgressDisplay: React.FC< + SubagentProgressDisplayProps +> = ({ progress }) => { + let headerText: string | undefined; + let headerColor = theme.text.secondary; + + if (progress.state === 'cancelled') { + headerText = `Subagent ${progress.agentName} was cancelled.`; + headerColor = theme.status.warning; + } else if (progress.state === 'error') { + headerText = `Subagent ${progress.agentName} failed.`; + headerColor = theme.status.error; + } else if (progress.state === 'completed') { + headerText = `Subagent ${progress.agentName} completed.`; + headerColor = theme.status.success; + } + + return ( + + {headerText && ( + + + {headerText} + + + )} + + {progress.recentActivity.map((item: SubagentActivityItem) => { + if (item.type === 'thought') { + const isCancellation = item.content === 'Request cancelled.'; + const icon = isCancellation ? 'ℹ ' : '💭'; + const color = isCancellation + ? theme.status.warning + : theme.text.secondary; + + return ( + + + {icon} + + + {item.content} + + + ); + } else if (item.type === 'tool_call') { + const statusSymbol = + item.status === 'running' ? ( + + ) : item.status === 'completed' ? ( + {TOOL_STATUS.SUCCESS} + ) : item.status === 'cancelled' ? ( + + {TOOL_STATUS.CANCELED} + + ) : ( + {TOOL_STATUS.ERROR} + ); + + const formattedArgs = item.description || formatToolArgs(item.args); + const displayArgs = + formattedArgs.length > 60 + ? formattedArgs.slice(0, 60) + '...' + : formattedArgs; + + return ( + + {statusSymbol} + + + {item.displayName || item.content} + + {displayArgs && ( + + + {displayArgs} + + + )} + + + ); + } + return null; + })} + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 29e485a27c..5ec2a18e06 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -75,6 +75,7 @@ export const ToolGroupMessage: React.FC = ({ status: t.status, approvalMode: t.approvalMode, hasResultDisplay: !!t.resultDisplay, + parentCallId: t.parentCallId, }); }), [allToolCalls, isLowErrorVerbosity], diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 8e0fc4442a..1c29407e91 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -11,7 +11,11 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme } from '../../semantic-colors.js'; -import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core'; +import { + type AnsiOutput, + type AnsiLine, + isSubagentProgress, +} from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; @@ -20,6 +24,7 @@ import { ScrollableList } from '../shared/ScrollableList.js'; import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js'; import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js'; +import { SubagentProgressDisplay } from './SubagentProgressDisplay.js'; // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. @@ -167,6 +172,8 @@ export const ToolResultDisplay: React.FC = ({ {formattedJSON} ); + } else if (isSubagentProgress(truncatedResultDisplay)) { + content = ; } else if ( typeof truncatedResultDisplay === 'string' && renderOutputAsMarkdown diff --git a/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap new file mode 100644 index 0000000000..8a4c5bd4c4 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/SubagentProgressDisplay.test.tsx.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders "Request cancelled." with the info icon 1`] = ` +"ℹ Request cancelled. +" +`; + +exports[` > renders cancelled state correctly 1`] = ` +"Subagent TestAgent was cancelled. +" +`; + +exports[` > renders correctly with command fallback 1`] = ` +"⠋ run_shell_command echo hello +" +`; + +exports[` > renders correctly with description in args 1`] = ` +"⠋ run_shell_command Say hello +" +`; + +exports[` > renders correctly with displayName and description from item 1`] = ` +"⠋ RunShellCommand Executing echo hello +" +`; + +exports[` > renders correctly with file_path 1`] = ` +"✓ write_file /tmp/test.txt +" +`; + +exports[` > renders thought bubbles correctly 1`] = ` +"💭 Thinking about life +" +`; + +exports[` > truncates long args 1`] = ` +"⠋ run_shell_command This is a very long description that should definitely be tr... +" +`; diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index db9df81566..1bc6d09903 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -48,6 +48,7 @@ export function mapToDisplay( const baseDisplayProperties = { callId: call.request.callId, + parentCallId: call.request.parentCallId, name: displayName, description, renderOutputAsMarkdown, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 55048ef6bc..2a8e66789c 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -98,6 +98,7 @@ export interface ToolCallEvent { export interface IndividualToolCallDisplay { callId: string; + parentCallId?: string; name: string; description: string; resultDisplay: ToolResultDisplay | undefined; diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 0de9564c39..9df543300e 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -16,8 +16,11 @@ import type { Config } from '../../config/config.js'; import { LocalAgentExecutor } from '../local-executor.js'; -import type { AnsiOutput } from '../../utils/terminalSerializer.js'; -import { BaseToolInvocation, type ToolResult } from '../../tools/tools.js'; +import { + BaseToolInvocation, + type ToolResult, + type ToolLiveOutput, +} from '../../tools/tools.js'; import { ToolErrorType } from '../../tools/tool-error.js'; import type { AgentInputs, SubagentActivityEvent } from '../types.js'; import type { MessageBus } from '../../confirmation-bus/message-bus.js'; @@ -82,7 +85,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation< */ async execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, ): Promise { let browserManager; diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index 8f7269b784..df8755015c 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -711,25 +711,28 @@ describe('LocalAgentExecutor', () => { expect.arrayContaining([ expect.objectContaining({ type: 'THOUGHT_CHUNK', - data: { text: 'T1: Listing' }, + data: expect.objectContaining({ text: 'T1: Listing' }), }), expect.objectContaining({ type: 'TOOL_CALL_END', - data: { name: LS_TOOL_NAME, output: 'file1.txt' }, + data: expect.objectContaining({ + name: LS_TOOL_NAME, + output: 'file1.txt', + }), }), expect.objectContaining({ type: 'TOOL_CALL_START', - data: { + data: expect.objectContaining({ name: TASK_COMPLETE_TOOL_NAME, args: { finalResult: 'Found file1.txt' }, - }, + }), }), expect.objectContaining({ type: 'TOOL_CALL_END', - data: { + data: expect.objectContaining({ name: TASK_COMPLETE_TOOL_NAME, output: expect.stringContaining('Output submitted'), - }, + }), }), ]), ); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 513424ad32..47217213f7 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -269,13 +269,22 @@ export class LocalAgentExecutor { }; } - const { nextMessage, submittedOutput, taskCompleted } = + const { nextMessage, submittedOutput, taskCompleted, aborted } = await this.processFunctionCalls( functionCalls, combinedSignal, promptId, onWaitingForConfirmation, ); + + if (aborted) { + return { + status: 'stop', + terminateReason: AgentTerminateMode.ABORTED, + finalResult: null, + }; + } + if (taskCompleted) { const finalResult = submittedOutput ?? 'Task completed successfully.'; return { @@ -857,6 +866,7 @@ export class LocalAgentExecutor { nextMessage: Content; submittedOutput: string | null; taskCompleted: boolean; + aborted: boolean; }> { const allowedToolNames = new Set(this.toolRegistry.getAllToolNames()); // Always allow the completion tool @@ -864,6 +874,7 @@ export class LocalAgentExecutor { let submittedOutput: string | null = null; let taskCompleted = false; + let aborted = false; // We'll separate complete_task from other tools const toolRequests: ToolCallRequestInfo[] = []; @@ -878,8 +889,24 @@ export class LocalAgentExecutor { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const toolName = functionCall.name as string; + let displayName = toolName; + let description: string | undefined = undefined; + + try { + const tool = this.toolRegistry.getTool(toolName); + if (tool) { + displayName = tool.displayName ?? toolName; + const invocation = tool.build(args); + description = invocation.getDescription(); + } + } catch { + // Ignore errors during formatting for activity emission + } + this.emitActivity('TOOL_CALL_START', { name: toolName, + displayName, + description, args, }); @@ -1077,8 +1104,9 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { context: 'tool_call', name: toolName, - error: 'Tool call was cancelled.', + error: 'Request cancelled.', }); + aborted = true; } // Add result to syncResults to preserve order later @@ -1111,6 +1139,7 @@ export class LocalAgentExecutor { nextMessage: { role: 'user', parts: toolResponseParts }, submittedOutput, taskCompleted, + aborted, }; } diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 91efcd399f..77509881af 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -4,17 +4,25 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mocked, +} from 'vitest'; import type { LocalAgentDefinition, SubagentActivityEvent, AgentInputs, + SubagentProgress, } from './types.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { LocalAgentExecutor } from './local-executor.js'; import { AgentTerminateMode } from './types.js'; import { makeFakeConfig } from '../test-utils/config.js'; -import { ToolErrorType } from '../tools/tool-error.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { type z } from 'zod'; @@ -29,6 +37,7 @@ let mockConfig: Config; const testDefinition: LocalAgentDefinition = { kind: 'local', name: 'MockAgent', + displayName: 'Mock Agent', description: 'A mock agent.', inputConfig: { inputSchema: { @@ -70,6 +79,10 @@ describe('LocalSubagentInvocation', () => { ); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should pass the messageBus to the parent constructor', () => { const params = { task: 'Analyze data' }; const invocation = new LocalSubagentInvocation( @@ -173,7 +186,12 @@ describe('LocalSubagentInvocation', () => { mockConfig, expect.any(Function), ); - expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n'); + expect(updateOutput).toHaveBeenCalledWith( + expect.objectContaining({ + isSubagentProgress: true, + agentName: 'MockAgent', + }), + ); expect(mockExecutorInstance.run).toHaveBeenCalledWith(params, signal); @@ -211,13 +229,17 @@ describe('LocalSubagentInvocation', () => { await invocation.execute(signal, updateOutput); - expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n'); - expect(updateOutput).toHaveBeenCalledWith('🤖💭 Analyzing...'); - expect(updateOutput).toHaveBeenCalledWith('🤖💭 Still thinking.'); - expect(updateOutput).toHaveBeenCalledTimes(3); // Initial message + 2 thoughts + expect(updateOutput).toHaveBeenCalledTimes(3); // Initial + 2 updates + const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress; + expect(lastCall.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: 'Analyzing... Still thinking.', + }), + ); }); - it('should NOT stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => { + it('should stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => { mockExecutorInstance.run.mockImplementation(async () => { const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2]; @@ -226,7 +248,7 @@ describe('LocalSubagentInvocation', () => { isSubagentActivityEvent: true, agentName: 'MockAgent', type: 'TOOL_CALL_START', - data: { name: 'ls' }, + data: { name: 'ls', args: {} }, } as SubagentActivityEvent); onActivity({ isSubagentActivityEvent: true, @@ -240,9 +262,15 @@ describe('LocalSubagentInvocation', () => { await invocation.execute(signal, updateOutput); - // Should only contain the initial "Subagent starting..." message - expect(updateOutput).toHaveBeenCalledTimes(1); - expect(updateOutput).toHaveBeenCalledWith('Subagent starting...\n'); + expect(updateOutput).toHaveBeenCalledTimes(3); + const lastCall = updateOutput.mock.calls[2][0] as SubagentProgress; + expect(lastCall.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: 'Error: Failed', + status: 'error', + }), + ); }); it('should run successfully without an updateOutput callback', async () => { @@ -272,16 +300,19 @@ describe('LocalSubagentInvocation', () => { const result = await invocation.execute(signal, updateOutput); - expect(result.error).toEqual({ - message: error.message, - type: ToolErrorType.EXECUTION_FAILED, - }); - expect(result.returnDisplay).toBe( - `Subagent Failed: MockAgent\nError: ${error.message}`, - ); + expect(result.error).toBeUndefined(); expect(result.llmContent).toBe( `Subagent 'MockAgent' failed. Error: ${error.message}`, ); + const display = result.returnDisplay as SubagentProgress; + expect(display.isSubagentProgress).toBe(true); + expect(display.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: `Error: ${error.message}`, + status: 'error', + }), + ); }); it('should handle executor creation failure', async () => { @@ -291,19 +322,21 @@ describe('LocalSubagentInvocation', () => { const result = await invocation.execute(signal, updateOutput); expect(mockExecutorInstance.run).not.toHaveBeenCalled(); - expect(result.error).toEqual({ - message: creationError.message, - type: ToolErrorType.EXECUTION_FAILED, - }); - expect(result.returnDisplay).toContain(`Error: ${creationError.message}`); + expect(result.error).toBeUndefined(); + expect(result.llmContent).toContain(creationError.message); + + const display = result.returnDisplay as SubagentProgress; + expect(display.recentActivity).toContainEqual( + expect.objectContaining({ + content: `Error: ${creationError.message}`, + status: 'error', + }), + ); }); - /** - * This test verifies that the AbortSignal is correctly propagated and - * that a rejection from the executor due to abortion is handled gracefully. - */ it('should handle abortion signal during execution', async () => { const abortError = new Error('Aborted'); + abortError.name = 'AbortError'; mockExecutorInstance.run.mockRejectedValue(abortError); const controller = new AbortController(); @@ -312,14 +345,24 @@ describe('LocalSubagentInvocation', () => { updateOutput, ); controller.abort(); - const result = await executePromise; + await expect(executePromise).rejects.toThrow('Aborted'); expect(mockExecutorInstance.run).toHaveBeenCalledWith( params, controller.signal, ); - expect(result.error?.message).toBe('Aborted'); - expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED); + }); + + it('should throw an error and bubble cancellation when execution returns ABORTED', async () => { + const mockOutput = { + result: 'Cancelled by user', + terminate_reason: AgentTerminateMode.ABORTED, + }; + mockExecutorInstance.run.mockResolvedValue(mockOutput); + + await expect(invocation.execute(signal, updateOutput)).rejects.toThrow( + 'Operation cancelled by user', + ); }); }); }); diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index a75fa8a11a..4bd2bc171a 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -6,18 +6,25 @@ import type { Config } from '../config/config.js'; import { LocalAgentExecutor } from './local-executor.js'; -import type { AnsiOutput } from '../utils/terminalSerializer.js'; -import { BaseToolInvocation, type ToolResult } from '../tools/tools.js'; -import { ToolErrorType } from '../tools/tool-error.js'; -import type { - LocalAgentDefinition, - AgentInputs, - SubagentActivityEvent, +import { + BaseToolInvocation, + type ToolResult, + type ToolLiveOutput, +} from '../tools/tools.js'; +import { + type LocalAgentDefinition, + type AgentInputs, + type SubagentActivityEvent, + type SubagentProgress, + type SubagentActivityItem, + AgentTerminateMode, } from './types.js'; +import { randomUUID } from 'node:crypto'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; const INPUT_PREVIEW_MAX_LENGTH = 50; const DESCRIPTION_MAX_LENGTH = 200; +const MAX_RECENT_ACTIVITY = 3; /** * Represents a validated, executable instance of a subagent tool. @@ -81,11 +88,20 @@ export class LocalSubagentInvocation extends BaseToolInvocation< */ async execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, ): Promise { + let recentActivity: SubagentActivityItem[] = []; + try { if (updateOutput) { - updateOutput('Subagent starting...\n'); + // Send initial state + const initialProgress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [], + state: 'running', + }; + updateOutput(initialProgress); } // Create an activity callback to bridge the executor's events to the @@ -93,11 +109,114 @@ export class LocalSubagentInvocation extends BaseToolInvocation< const onActivity = (activity: SubagentActivityEvent): void => { if (!updateOutput) return; - if ( - activity.type === 'THOUGHT_CHUNK' && - typeof activity.data['text'] === 'string' - ) { - updateOutput(`🤖💭 ${activity.data['text']}`); + let updated = false; + + switch (activity.type) { + case 'THOUGHT_CHUNK': { + const text = String(activity.data['text']); + const lastItem = recentActivity[recentActivity.length - 1]; + if ( + lastItem && + lastItem.type === 'thought' && + lastItem.status === 'running' + ) { + lastItem.content += text; + } else { + recentActivity.push({ + id: randomUUID(), + type: 'thought', + content: text, + status: 'running', + }); + } + updated = true; + break; + } + case 'TOOL_CALL_START': { + const name = String(activity.data['name']); + const displayName = activity.data['displayName'] + ? String(activity.data['displayName']) + : undefined; + const description = activity.data['description'] + ? String(activity.data['description']) + : undefined; + const args = JSON.stringify(activity.data['args']); + recentActivity.push({ + id: randomUUID(), + type: 'tool_call', + content: name, + displayName, + description, + args, + status: 'running', + }); + updated = true; + break; + } + case 'TOOL_CALL_END': { + const name = String(activity.data['name']); + // Find the last running tool call with this name + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].content === name && + recentActivity[i].status === 'running' + ) { + recentActivity[i].status = 'completed'; + updated = true; + break; + } + } + break; + } + case 'ERROR': { + const error = String(activity.data['error']); + const isCancellation = error === 'Request cancelled.'; + const toolName = activity.data['name'] + ? String(activity.data['name']) + : undefined; + + if (toolName && isCancellation) { + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].content === toolName && + recentActivity[i].status === 'running' + ) { + recentActivity[i].status = 'cancelled'; + updated = true; + break; + } + } + } + + recentActivity.push({ + id: randomUUID(), + type: 'thought', // Treat errors as thoughts for now, or add an error type + content: isCancellation ? error : `Error: ${error}`, + status: isCancellation ? 'cancelled' : 'error', + }); + updated = true; + break; + } + default: + break; + } + + if (updated) { + // Keep only the last N items + if (recentActivity.length > MAX_RECENT_ACTIVITY) { + recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); + } + + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], // Copy to avoid mutation issues + state: 'running', + }; + + updateOutput(progress); } }; @@ -109,6 +228,23 @@ export class LocalSubagentInvocation extends BaseToolInvocation< const output = await executor.run(this.params, signal); + if (output.terminate_reason === AgentTerminateMode.ABORTED) { + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], + state: 'cancelled', + }; + + if (updateOutput) { + updateOutput(progress); + } + + const cancelError = new Error('Operation cancelled by user'); + cancelError.name = 'AbortError'; + throw cancelError; + } + const resultContent = `Subagent '${this.definition.name}' finished. Termination Reason: ${output.terminate_reason} Result: @@ -131,13 +267,55 @@ ${output.result} const errorMessage = error instanceof Error ? error.message : String(error); + const isAbort = + (error instanceof Error && error.name === 'AbortError') || + errorMessage.includes('Aborted'); + + // Mark any running items as error/cancelled + for (const item of recentActivity) { + if (item.status === 'running') { + item.status = isAbort ? 'cancelled' : 'error'; + } + } + + // Ensure the error is reflected in the recent activity for display + // But only if it's NOT an abort, or if we want to show "Cancelled" as a thought + if (!isAbort) { + const lastActivity = recentActivity[recentActivity.length - 1]; + if (!lastActivity || lastActivity.status !== 'error') { + recentActivity.push({ + id: randomUUID(), + type: 'thought', + content: `Error: ${errorMessage}`, + status: 'error', + }); + // Maintain size limit + if (recentActivity.length > MAX_RECENT_ACTIVITY) { + recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY); + } + } + } + + const progress: SubagentProgress = { + isSubagentProgress: true, + agentName: this.definition.name, + recentActivity: [...recentActivity], + state: isAbort ? 'cancelled' : 'error', + }; + + if (updateOutput) { + updateOutput(progress); + } + + if (isAbort) { + throw error; + } + return { llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`, - returnDisplay: `Subagent Failed: ${this.definition.name}\nError: ${errorMessage}`, - error: { - message: errorMessage, - type: ToolErrorType.EXECUTION_FAILED, - }, + returnDisplay: progress, + // We omit the 'error' property so that the UI renders our rich returnDisplay + // instead of the raw error message. The llmContent still informs the agent of the failure. }; } } diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts index 74f0051351..c6e90ea198 100644 --- a/packages/core/src/agents/subagent-tool.test.ts +++ b/packages/core/src/agents/subagent-tool.test.ts @@ -120,6 +120,16 @@ describe('SubAgentInvocation', () => { ); }); + it('should return the correct description', () => { + const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); + const params = {}; + // @ts-expect-error - accessing protected method for testing + const invocation = tool.createInvocation(params, mockMessageBus); + expect(invocation.getDescription()).toBe( + "Delegating to agent 'LocalAgent'", + ); + }); + it('should delegate shouldConfirmExecute to the inner sub-invocation (remote)', async () => { const tool = new SubagentTool( testRemoteDefinition, diff --git a/packages/core/src/agents/subagent-tool.ts b/packages/core/src/agents/subagent-tool.ts index 3ecff4e969..21a3864160 100644 --- a/packages/core/src/agents/subagent-tool.ts +++ b/packages/core/src/agents/subagent-tool.ts @@ -12,8 +12,8 @@ import { BaseToolInvocation, type ToolCallConfirmationDetails, isTool, + type ToolLiveOutput, } from '../tools/tools.js'; -import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { AgentDefinition, AgentInputs } from './types.js'; @@ -155,7 +155,7 @@ class SubAgentInvocation extends BaseToolInvocation { async execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, ): Promise { const validationError = SchemaValidator.validate( this.definition.inputConfig.inputSchema, diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 3704746810..ceac0909df 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -71,6 +71,32 @@ export interface SubagentActivityEvent { data: Record; } +export interface SubagentActivityItem { + id: string; + type: 'thought' | 'tool_call'; + content: string; + displayName?: string; + description?: string; + args?: string; + status: 'running' | 'completed' | 'error' | 'cancelled'; +} + +export interface SubagentProgress { + isSubagentProgress: true; + agentName: string; + recentActivity: SubagentActivityItem[]; + state?: 'running' | 'completed' | 'error' | 'cancelled'; +} + +export function isSubagentProgress(obj: unknown): obj is SubagentProgress { + return ( + typeof obj === 'object' && + obj !== null && + 'isSubagentProgress' in obj && + obj.isSubagentProgress === true + ); +} + /** * The base definition for an agent. * @template TOutput The specific Zod schema for the agent's final output object. diff --git a/packages/core/src/core/coreToolHookTriggers.ts b/packages/core/src/core/coreToolHookTriggers.ts index 9c83253903..cbd90e8039 100644 --- a/packages/core/src/core/coreToolHookTriggers.ts +++ b/packages/core/src/core/coreToolHookTriggers.ts @@ -10,10 +10,11 @@ import type { ToolResult, AnyDeclarativeTool, AnyToolInvocation, + ToolLiveOutput, } from '../tools/tools.js'; import { ToolErrorType } from '../tools/tool-error.js'; import { debugLogger } from '../utils/debugLogger.js'; -import type { AnsiOutput, ShellExecutionConfig } from '../index.js'; +import type { ShellExecutionConfig } from '../index.js'; import { ShellToolInvocation } from '../tools/shell.js'; import { DiscoveredMCPToolInvocation } from '../tools/mcp-tool.js'; @@ -71,7 +72,7 @@ export async function executeToolWithHooks( toolName: string, signal: AbortSignal, tool: AnyDeclarativeTool, - liveOutputCallback?: (outputChunk: string | AnsiOutput) => void, + liveOutputCallback?: (outputChunk: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, setPidCallback?: (pid: number) => void, config?: Config, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 6814f31402..789ea73ff1 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -189,11 +189,16 @@ export class InvalidStreamError extends Error { readonly type: | 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT' - | 'MALFORMED_FUNCTION_CALL'; + | 'MALFORMED_FUNCTION_CALL' + | 'UNEXPECTED_TOOL_CALL'; constructor( message: string, - type: 'NO_FINISH_REASON' | 'NO_RESPONSE_TEXT' | 'MALFORMED_FUNCTION_CALL', + type: + | 'NO_FINISH_REASON' + | 'NO_RESPONSE_TEXT' + | 'MALFORMED_FUNCTION_CALL' + | 'UNEXPECTED_TOOL_CALL', ) { super(message); this.name = 'InvalidStreamError'; @@ -935,6 +940,12 @@ export class GeminiChat { 'MALFORMED_FUNCTION_CALL', ); } + if (finishReason === FinishReason.UNEXPECTED_TOOL_CALL) { + throw new InvalidStreamError( + 'Model stream ended with unexpected tool call.', + 'UNEXPECTED_TOOL_CALL', + ); + } if (!responseText) { throw new InvalidStreamError( 'Model stream ended with empty response text.', diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index d37c49624c..e358c53c8b 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -9,7 +9,8 @@ import type { ToolCallResponseInfo, ToolResult, Config, - AnsiOutput, + ToolResultDisplay, + ToolLiveOutput, } from '../index.js'; import { ToolErrorType, @@ -45,7 +46,7 @@ import { export interface ToolExecutionContext { call: ToolCall; signal: AbortSignal; - outputUpdateHandler?: (callId: string, output: string | AnsiOutput) => void; + outputUpdateHandler?: (callId: string, output: ToolLiveOutput) => void; onUpdateToolCall: (updatedCall: ToolCall) => void; } @@ -68,7 +69,7 @@ export class ToolExecutor { // Setup live output handling const liveOutputCallback = tool.canUpdateOutput && outputUpdateHandler - ? (outputChunk: string | AnsiOutput) => { + ? (outputChunk: ToolLiveOutput) => { outputUpdateHandler(callId, outputChunk); } : undefined; @@ -134,6 +135,7 @@ export class ToolExecutor { completedToolCall = this.createCancelledResult( call, 'User cancelled tool execution.', + toolResult.returnDisplay, ); } else if (toolResult.error === undefined) { completedToolCall = await this.createSuccessResult( @@ -155,7 +157,12 @@ export class ToolExecutor { } } catch (executionError: unknown) { spanMetadata.error = executionError; - if (signal.aborted) { + const isAbortError = + executionError instanceof Error && + (executionError.name === 'AbortError' || + executionError.message.includes('Operation cancelled by user')); + + if (signal.aborted || isAbortError) { completedToolCall = this.createCancelledResult( call, 'User cancelled tool execution.', @@ -182,6 +189,7 @@ export class ToolExecutor { private createCancelledResult( call: ToolCall, reason: string, + resultDisplay?: ToolResultDisplay, ): CancelledToolCall { const errorMessage = `[Operation Cancelled] ${reason}`; const startTime = 'startTime' in call ? call.startTime : undefined; @@ -206,7 +214,7 @@ export class ToolExecutor { }, }, ], - resultDisplay: undefined, + resultDisplay, error: undefined, errorType: undefined, contentLength: errorMessage.length, diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index 7eaf07e94e..9fedd48f41 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -11,8 +11,8 @@ import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolResultDisplay, + ToolLiveOutput, } from '../tools/tools.js'; -import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { ToolErrorType } from '../tools/tool-error.js'; import type { SerializableConfirmationDetails } from '../confirmation-bus/types.js'; import { type ApprovalMode } from '../policy/types.js'; @@ -125,7 +125,7 @@ export type ExecutingToolCall = { request: ToolCallRequestInfo; tool: AnyDeclarativeTool; invocation: AnyToolInvocation; - liveOutput?: string | AnsiOutput; + liveOutput?: ToolLiveOutput; progressMessage?: string; progressPercent?: number; progress?: number; @@ -197,7 +197,7 @@ export type ConfirmHandler = ( export type OutputUpdateHandler = ( toolCallId: string, - outputChunk: string | AnsiOutput, + outputChunk: ToolLiveOutput, ) => void; export type AllToolCallsCompleteHandler = ( diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 741272f555..6afded3faa 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -17,6 +17,7 @@ import type { ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, PolicyUpdateOptions, + ToolLiveOutput, } from './tools.js'; import { BaseDeclarativeTool, @@ -149,7 +150,7 @@ export class ShellToolInvocation extends BaseToolInvocation< async execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, setPidCallback?: (pid: number) => void, ): Promise { diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 3c024168d4..0a82cc1510 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -19,6 +19,7 @@ import { type Question, } from '../confirmation-bus/types.js'; import { type ApprovalMode } from '../policy/types.js'; +import type { SubagentProgress } from '../agents/types.js'; /** * Represents a validated and ready-to-execute tool call. @@ -64,7 +65,7 @@ export interface ToolInvocation< */ execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, ): Promise; } @@ -276,7 +277,7 @@ export abstract class BaseToolInvocation< abstract execute( signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, ): Promise; } @@ -422,7 +423,7 @@ export abstract class DeclarativeTool< async buildAndExecute( params: TParams, signal: AbortSignal, - updateOutput?: (output: string | AnsiOutput) => void, + updateOutput?: (output: ToolLiveOutput) => void, shellExecutionConfig?: ShellExecutionConfig, ): Promise { const invocation = this.build(params); @@ -688,7 +689,14 @@ export interface TodoList { todos: Todo[]; } -export type ToolResultDisplay = string | FileDiff | AnsiOutput | TodoList; +export type ToolLiveOutput = string | AnsiOutput | SubagentProgress; + +export type ToolResultDisplay = + | string + | FileDiff + | AnsiOutput + | TodoList + | SubagentProgress; export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; diff --git a/packages/core/src/utils/tool-utils.test.ts b/packages/core/src/utils/tool-utils.test.ts index 225889d53a..c007b37715 100644 --- a/packages/core/src/utils/tool-utils.test.ts +++ b/packages/core/src/utils/tool-utils.test.ts @@ -98,6 +98,17 @@ describe('shouldHideToolCall', () => { ).toBe(!visible); }, ); + + it('hides tool calls with a parentCallId', () => { + expect( + shouldHideToolCall({ + displayName: 'any_tool', + status: CoreToolCallStatus.Success, + hasResultDisplay: true, + parentCallId: 'some-parent', + }), + ).toBe(true); + }); }); describe('getToolSuggestion', () => { diff --git a/packages/core/src/utils/tool-utils.ts b/packages/core/src/utils/tool-utils.ts index b8e60fe4ce..17ccbda8d6 100644 --- a/packages/core/src/utils/tool-utils.ts +++ b/packages/core/src/utils/tool-utils.ts @@ -28,20 +28,28 @@ export interface ShouldHideToolCallParams { approvalMode?: ApprovalMode; /** Whether the tool has produced a result for display. */ hasResultDisplay: boolean; + /** The ID of the parent tool call, if any. */ + parentCallId?: string; } /** * Determines if a tool call should be hidden from the standard tool history UI. * * We hide tools in several cases: - * 1. Ask User tools that are in progress, displayed via specialized UI. - * 2. Ask User tools that errored without result display, typically param + * 1. Tool calls that have a parent, as they are "internal" to another tool (e.g. subagent). + * 2. Ask User tools that are in progress, displayed via specialized UI. + * 3. Ask User tools that errored without result display, typically param * validation errors that the agent automatically recovers from. - * 3. WriteFile and Edit tools when in Plan Mode, redundant because the + * 4. WriteFile and Edit tools when in Plan Mode, redundant because the * resulting plans are displayed separately upon exiting plan mode. */ export function shouldHideToolCall(params: ShouldHideToolCallParams): boolean { - const { displayName, status, approvalMode, hasResultDisplay } = params; + const { displayName, status, approvalMode, hasResultDisplay, parentCallId } = + params; + + if (parentCallId) { + return true; + } switch (displayName) { case ASK_USER_DISPLAY_NAME: From 1502e5cbc3c59a9e4bb6275dadf37d1c7545c48f Mon Sep 17 00:00:00 2001 From: Abdul Tawab <122252873+AbdulTawabJuly@users.noreply.github.com> Date: Tue, 3 Mar 2026 02:12:05 +0500 Subject: [PATCH 11/21] style(cli) : Dialog pattern for /hooks Command (#17930) --- .../cli/src/ui/commands/hooksCommand.test.ts | 46 ++-- packages/cli/src/ui/commands/hooksCommand.ts | 29 +- .../src/ui/components/HistoryItemDisplay.tsx | 4 - .../src/ui/components/HooksDialog.test.tsx | 248 ++++++++++++++++++ .../cli/src/ui/components/HooksDialog.tsx | 247 +++++++++++++++++ .../__snapshots__/HooksDialog.test.tsx.snap | 124 +++++++++ .../cli/src/ui/components/views/HooksList.tsx | 126 --------- packages/cli/src/ui/types.ts | 16 +- 8 files changed, 653 insertions(+), 187 deletions(-) create mode 100644 packages/cli/src/ui/components/HooksDialog.test.tsx create mode 100644 packages/cli/src/ui/components/HooksDialog.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap delete mode 100644 packages/cli/src/ui/components/views/HooksList.tsx diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index ed7f7bb747..8e5c54d17d 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -7,7 +7,6 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { hooksCommand } from './hooksCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import { MessageType } from '../types.js'; import type { HookRegistryEntry } from '@google/gemini-cli-core'; import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core'; import type { CommandContext } from './types.js'; @@ -127,13 +126,10 @@ describe('hooksCommand', () => { createMockHook('test-hook', HookEventName.BeforeTool, true), ]); - await hooksCommand.action(mockContext, ''); + const result = await hooksCommand.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); }); @@ -161,7 +157,7 @@ describe('hooksCommand', () => { }); }); - it('should display panel even when hook system is not enabled', async () => { + it('should return custom_dialog even when hook system is not enabled', async () => { mockConfig.getHookSystem.mockReturnValue(null); const panelCmd = hooksCommand.subCommands!.find( @@ -171,17 +167,13 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: [], - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); - it('should display panel when no hooks are configured', async () => { + it('should return custom_dialog when no hooks are configured', async () => { mockHookSystem.getAllHooks.mockReturnValue([]); (mockContext.services.settings.merged as Record)[ 'hooksConfig' @@ -194,17 +186,13 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: [], - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); - it('should display hooks list when hooks are configured', async () => { + it('should return custom_dialog when hooks are configured', async () => { const mockHooks: HookRegistryEntry[] = [ createMockHook('echo-test', HookEventName.BeforeTool, true), createMockHook('notify', HookEventName.AfterAgent, false), @@ -222,14 +210,10 @@ describe('hooksCommand', () => { throw new Error('panel command must have an action'); } - await panelCmd.action(mockContext, ''); + const result = await panelCmd.action(mockContext, ''); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.HOOKS_LIST, - hooks: mockHooks, - }), - ); + expect(result).toHaveProperty('type', 'custom_dialog'); + expect(result).toHaveProperty('component'); }); }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 92fa72b235..bc51f42037 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -4,9 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, CommandContext } from './types.js'; +import { createElement } from 'react'; +import type { + SlashCommand, + CommandContext, + OpenCustomDialogActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; -import { MessageType, type HistoryItemHooksList } from '../types.js'; import type { HookRegistryEntry, MessageActionReturn, @@ -15,13 +19,14 @@ import { getErrorMessage } from '@google/gemini-cli-core'; import { SettingScope, isLoadableSettingScope } from '../../config/settings.js'; import { enableHook, disableHook } from '../../utils/hookSettings.js'; import { renderHookActionFeedback } from '../../utils/hookUtils.js'; +import { HooksDialog } from '../components/HooksDialog.js'; /** - * Display a formatted list of hooks with their status + * Display a formatted list of hooks with their status in a dialog */ -async function panelAction( +function panelAction( context: CommandContext, -): Promise { +): MessageActionReturn | OpenCustomDialogActionReturn { const { config } = context.services; if (!config) { return { @@ -34,12 +39,13 @@ async function panelAction( const hookSystem = config.getHookSystem(); const allHooks = hookSystem?.getAllHooks() || []; - const hooksListItem: HistoryItemHooksList = { - type: MessageType.HOOKS_LIST, - hooks: allHooks, + return { + type: 'custom_dialog', + component: createElement(HooksDialog, { + hooks: allHooks, + onClose: () => context.ui.removeComponent(), + }), }; - - context.ui.addItem(hooksListItem); } /** @@ -343,6 +349,7 @@ const panelCommand: SlashCommand = { altNames: ['list', 'show'], description: 'Display all registered hooks with their status', kind: CommandKind.BUILT_IN, + autoExecute: true, action: panelAction, }; @@ -393,5 +400,5 @@ export const hooksCommand: SlashCommand = { enableAllCommand, disableAllCommand, ], - action: async (context: CommandContext) => panelCommand.action!(context, ''), + action: (context: CommandContext) => panelCommand.action!(context, ''), }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 458452d795..5076367115 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -32,7 +32,6 @@ import { SkillsList } from './views/SkillsList.js'; import { AgentsStatus } from './views/AgentsStatus.js'; import { McpStatus } from './views/McpStatus.js'; import { ChatList } from './views/ChatList.js'; -import { HooksList } from './views/HooksList.js'; import { ModelMessage } from './messages/ModelMessage.js'; import { ThinkingMessage } from './messages/ThinkingMessage.js'; import { HintMessage } from './messages/HintMessage.js'; @@ -217,9 +216,6 @@ export const HistoryItemDisplay: React.FC = ({ {itemForDisplay.type === 'chat_list' && ( )} - {itemForDisplay.type === 'hooks_list' && ( - - )} ); }; diff --git a/packages/cli/src/ui/components/HooksDialog.test.tsx b/packages/cli/src/ui/components/HooksDialog.test.tsx new file mode 100644 index 0000000000..1bddb759ba --- /dev/null +++ b/packages/cli/src/ui/components/HooksDialog.test.tsx @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../test-utils/render.js'; +import { act } from 'react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { HooksDialog, type HookEntry } from './HooksDialog.js'; + +describe('HooksDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createMockHook = ( + name: string, + eventName: string, + enabled: boolean, + options?: Partial, + ): HookEntry => ({ + config: { + name, + command: `run-${name}`, + type: 'command', + description: `Test hook: ${name}`, + ...options?.config, + }, + source: options?.source ?? '/mock/path/GEMINI.md', + eventName, + enabled, + ...options, + }); + + describe('snapshots', () => { + it('renders empty hooks dialog', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders single hook with security warning, source, and tips', async () => { + const hooks = [createMockHook('test-hook', 'before-tool', true)]; + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders hooks grouped by event name with enabled and disabled status', async () => { + const hooks = [ + createMockHook('hook1', 'before-tool', true), + createMockHook('hook2', 'before-tool', false), + createMockHook('hook3', 'after-agent', true), + ]; + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders hook with all metadata (matcher, sequential, timeout)', async () => { + const hooks = [ + createMockHook('my-hook', 'before-tool', true, { + matcher: 'shell_exec', + sequential: true, + config: { + name: 'my-hook', + type: 'command', + description: 'A hook with all metadata fields', + timeout: 30, + }, + }), + ]; + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders hook using command as name when name is not provided', async () => { + const hooks: HookEntry[] = [ + { + config: { + command: 'echo hello', + type: 'command', + }, + source: '/mock/path', + eventName: 'before-tool', + enabled: true, + }, + ]; + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + }); + + describe('keyboard interaction', () => { + it('should call onClose when escape key is pressed', async () => { + const onClose = vi.fn(); + const { waitUntilReady, stdin, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + act(() => { + stdin.write('\u001b[27u'); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + unmount(); + }); + }); + + describe('scrolling behavior', () => { + const createManyHooks = (count: number): HookEntry[] => + Array.from({ length: count }, (_, i) => + createMockHook(`hook-${i + 1}`, `event-${(i % 3) + 1}`, i % 2 === 0), + ); + + it('should not show scroll indicators when hooks fit within maxVisibleHooks', async () => { + const hooks = [ + createMockHook('hook1', 'before-tool', true), + createMockHook('hook2', 'after-tool', false), + ]; + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + expect(lastFrame()).not.toContain('▲'); + expect(lastFrame()).not.toContain('▼'); + unmount(); + }); + + it('should show scroll down indicator when there are more hooks than maxVisibleHooks', async () => { + const hooks = createManyHooks(15); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + expect(lastFrame()).toContain('▼'); + unmount(); + }); + + it('should scroll down when down arrow is pressed', async () => { + const hooks = createManyHooks(15); + const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + // Initially should not show up indicator + expect(lastFrame()).not.toContain('▲'); + + act(() => { + stdin.write('\u001b[B'); + }); + await waitUntilReady(); + + // Should now show up indicator after scrolling down + expect(lastFrame()).toContain('▲'); + unmount(); + }); + + it('should scroll up when up arrow is pressed after scrolling down', async () => { + const hooks = createManyHooks(15); + const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + // Scroll down twice + act(() => { + stdin.write('\u001b[B'); + stdin.write('\u001b[B'); + }); + await waitUntilReady(); + + expect(lastFrame()).toContain('▲'); + + // Scroll up once + act(() => { + stdin.write('\u001b[A'); + }); + await waitUntilReady(); + + // Should still show up indicator (scrolled down once) + expect(lastFrame()).toContain('▲'); + unmount(); + }); + + it('should not scroll beyond the end', async () => { + const hooks = createManyHooks(10); + const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + // Scroll down many times past the end + act(() => { + for (let i = 0; i < 20; i++) { + stdin.write('\u001b[B'); + } + }); + await waitUntilReady(); + + const frame = lastFrame(); + expect(frame).toContain('▲'); + // At the end, down indicator should be hidden + expect(frame).not.toContain('▼'); + unmount(); + }); + + it('should not scroll above the beginning', async () => { + const hooks = createManyHooks(10); + const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + // Try to scroll up when already at top + act(() => { + stdin.write('\u001b[A'); + }); + await waitUntilReady(); + + expect(lastFrame()).not.toContain('▲'); + expect(lastFrame()).toContain('▼'); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/HooksDialog.tsx b/packages/cli/src/ui/components/HooksDialog.tsx new file mode 100644 index 0000000000..d820aba6e7 --- /dev/null +++ b/packages/cli/src/ui/components/HooksDialog.tsx @@ -0,0 +1,247 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; + +/** + * Hook entry type matching HookRegistryEntry from core + */ +export interface HookEntry { + config: { + command?: string; + type: string; + name?: string; + description?: string; + timeout?: number; + }; + source: string; + eventName: string; + matcher?: string; + sequential?: boolean; + enabled: boolean; +} + +interface HooksDialogProps { + hooks: readonly HookEntry[]; + onClose: () => void; + /** Maximum number of hooks to display at once before scrolling. Default: 8 */ + maxVisibleHooks?: number; +} + +/** Maximum hooks to show at once before scrolling is needed */ +const DEFAULT_MAX_VISIBLE_HOOKS = 8; + +/** + * Dialog component for displaying hooks in a styled box. + * Replaces inline chat history display with a modal-style dialog. + * Supports scrolling with up/down arrow keys when there are many hooks. + */ +export const HooksDialog: React.FC = ({ + hooks, + onClose, + maxVisibleHooks = DEFAULT_MAX_VISIBLE_HOOKS, +}) => { + const [scrollOffset, setScrollOffset] = useState(0); + + // Flatten hooks with their event names for easier scrolling + const flattenedHooks = useMemo(() => { + const result: Array<{ + type: 'header' | 'hook'; + eventName: string; + hook?: HookEntry; + }> = []; + + // Group hooks by event name + const hooksByEvent = hooks.reduce( + (acc, hook) => { + if (!acc[hook.eventName]) { + acc[hook.eventName] = []; + } + acc[hook.eventName].push(hook); + return acc; + }, + {} as Record, + ); + + // Flatten into displayable items + Object.entries(hooksByEvent).forEach(([eventName, eventHooks]) => { + result.push({ type: 'header', eventName }); + eventHooks.forEach((hook) => { + result.push({ type: 'hook', eventName, hook }); + }); + }); + + return result; + }, [hooks]); + + const totalItems = flattenedHooks.length; + const needsScrolling = totalItems > maxVisibleHooks; + const maxScrollOffset = Math.max(0, totalItems - maxVisibleHooks); + + // Handle keyboard navigation + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); + return true; + } + + // Scroll navigation + if (needsScrolling) { + if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { + setScrollOffset((prev) => Math.max(0, prev - 1)); + return true; + } + if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { + setScrollOffset((prev) => Math.min(maxScrollOffset, prev + 1)); + return true; + } + } + + return false; + }, + { isActive: true }, + ); + + // Get visible items based on scroll offset + const visibleItems = needsScrolling + ? flattenedHooks.slice(scrollOffset, scrollOffset + maxVisibleHooks) + : flattenedHooks; + + const showScrollUp = needsScrolling && scrollOffset > 0; + const showScrollDown = needsScrolling && scrollOffset < maxScrollOffset; + + return ( + + {hooks.length === 0 ? ( + <> + No hooks configured. + + ) : ( + <> + {/* Security Warning */} + + + Security Warning: + + + Hooks can execute arbitrary commands on your system. Only use + hooks from sources you trust. Review hook scripts carefully. + + + + {/* Learn more link */} + + + Learn more:{' '} + + https://geminicli.com/docs/hooks + + + + + {/* Configured Hooks heading */} + + + Configured Hooks + + + + {/* Scroll up indicator */} + {showScrollUp && ( + + + + )} + + {/* Visible hooks */} + + {visibleItems.map((item, index) => { + if (item.type === 'header') { + return ( + + + {item.eventName} + + + ); + } + + const hook = item.hook!; + const hookName = + hook.config.name || hook.config.command || 'unknown'; + const hookKey = `${item.eventName}:${hook.source}:${hook.config.name ?? ''}:${hook.config.command ?? ''}`; + const statusColor = hook.enabled + ? theme.status.success + : theme.text.secondary; + const statusText = hook.enabled ? 'enabled' : 'disabled'; + + return ( + + + + {hookName} + + {` [${statusText}]`} + + + {hook.config.description && ( + + {hook.config.description} + + )} + + Source: {hook.source} + {hook.config.name && + hook.config.command && + ` | Command: ${hook.config.command}`} + {hook.matcher && ` | Matcher: ${hook.matcher}`} + {hook.sequential && ` | Sequential`} + {hook.config.timeout && + ` | Timeout: ${hook.config.timeout}s`} + + + + ); + })} + + + {/* Scroll down indicator */} + {showScrollDown && ( + + + + )} + + {/* Tips */} + + + Tip: Use /hooks enable {''} or{' '} + /hooks disable {''} to toggle + individual hooks. Use /hooks enable-all or{' '} + /hooks disable-all to toggle all hooks at once. + + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap new file mode 100644 index 0000000000..1a2271cc45 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/HooksDialog.test.tsx.snap @@ -0,0 +1,124 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`HooksDialog > snapshots > renders empty hooks dialog 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No hooks configured. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`HooksDialog > snapshots > renders hook using command as name when name is not provided 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Security Warning: │ +│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │ +│ Review hook scripts carefully. │ +│ │ +│ Learn more: https://geminicli.com/docs/hooks │ +│ │ +│ Configured Hooks │ +│ │ +│ before-tool │ +│ │ +│ echo hello [enabled] │ +│ Source: /mock/path │ +│ │ +│ │ +│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │ +│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`HooksDialog > snapshots > renders hook with all metadata (matcher, sequential, timeout) 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Security Warning: │ +│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │ +│ Review hook scripts carefully. │ +│ │ +│ Learn more: https://geminicli.com/docs/hooks │ +│ │ +│ Configured Hooks │ +│ │ +│ before-tool │ +│ │ +│ my-hook [enabled] │ +│ A hook with all metadata fields │ +│ Source: /mock/path/GEMINI.md | Matcher: shell_exec | Sequential | Timeout: 30s │ +│ │ +│ │ +│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │ +│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`HooksDialog > snapshots > renders hooks grouped by event name with enabled and disabled status 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Security Warning: │ +│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │ +│ Review hook scripts carefully. │ +│ │ +│ Learn more: https://geminicli.com/docs/hooks │ +│ │ +│ Configured Hooks │ +│ │ +│ before-tool │ +│ │ +│ hook1 [enabled] │ +│ Test hook: hook1 │ +│ Source: /mock/path/GEMINI.md | Command: run-hook1 │ +│ │ +│ hook2 [disabled] │ +│ Test hook: hook2 │ +│ Source: /mock/path/GEMINI.md | Command: run-hook2 │ +│ │ +│ after-agent │ +│ │ +│ hook3 [enabled] │ +│ Test hook: hook3 │ +│ Source: /mock/path/GEMINI.md | Command: run-hook3 │ +│ │ +│ │ +│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │ +│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`HooksDialog > snapshots > renders single hook with security warning, source, and tips 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Security Warning: │ +│ Hooks can execute arbitrary commands on your system. Only use hooks from sources you trust. │ +│ Review hook scripts carefully. │ +│ │ +│ Learn more: https://geminicli.com/docs/hooks │ +│ │ +│ Configured Hooks │ +│ │ +│ before-tool │ +│ │ +│ test-hook [enabled] │ +│ Test hook: test-hook │ +│ Source: /mock/path/GEMINI.md | Command: run-test-hook │ +│ │ +│ │ +│ Tip: Use /hooks enable or /hooks disable to toggle individual hooks. Use │ +│ /hooks enable-all or /hooks disable-all to toggle all hooks at once. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/components/views/HooksList.tsx b/packages/cli/src/ui/components/views/HooksList.tsx deleted file mode 100644 index bce3fcf870..0000000000 --- a/packages/cli/src/ui/components/views/HooksList.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface HooksListProps { - hooks: ReadonlyArray<{ - config: { - command?: string; - type: string; - name?: string; - description?: string; - timeout?: number; - }; - source: string; - eventName: string; - matcher?: string; - sequential?: boolean; - enabled: boolean; - }>; -} - -export const HooksList: React.FC = ({ hooks }) => { - if (hooks.length === 0) { - return ( - - No hooks configured. - - ); - } - - // Group hooks by event name for better organization - const hooksByEvent = hooks.reduce( - (acc, hook) => { - if (!acc[hook.eventName]) { - acc[hook.eventName] = []; - } - acc[hook.eventName].push(hook); - return acc; - }, - {} as Record>, - ); - - return ( - - - - ⚠️ Security Warning: - - - Hooks can execute arbitrary commands on your system. Only use hooks - from sources you trust. Review hook scripts carefully. - - - - - - Learn more:{' '} - https://geminicli.com/docs/hooks - - - - - Configured Hooks: - - - {Object.entries(hooksByEvent).map(([eventName, eventHooks]) => ( - - - {eventName}: - - - {eventHooks.map((hook, index) => { - const hookName = - hook.config.name || hook.config.command || 'unknown'; - const statusColor = hook.enabled - ? theme.status.success - : theme.text.secondary; - const statusText = hook.enabled ? 'enabled' : 'disabled'; - - return ( - - - - {hookName} - {` [${statusText}]`} - - - - {hook.config.description && ( - {hook.config.description} - )} - - Source: {hook.source} - {hook.config.name && - hook.config.command && - ` | Command: ${hook.config.command}`} - {hook.matcher && ` | Matcher: ${hook.matcher}`} - {hook.sequential && ` | Sequential`} - {hook.config.timeout && - ` | Timeout: ${hook.config.timeout}s`} - - - - ); - })} - - - ))} - - - - Tip: Use /hooks enable {''} or{' '} - /hooks disable {''} to toggle individual - hooks. Use /hooks enable-all or{' '} - /hooks disable-all to toggle all hooks at once. - - - - ); -}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 2a8e66789c..c8616dc114 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -349,18 +349,6 @@ export type HistoryItemMcpStatus = HistoryItemBase & { showSchema: boolean; }; -export type HistoryItemHooksList = HistoryItemBase & { - type: 'hooks_list'; - hooks: Array<{ - config: { command?: string; type: string; timeout?: number }; - source: string; - eventName: string; - matcher?: string; - sequential?: boolean; - enabled: boolean; - }>; -}; - // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -389,8 +377,7 @@ export type HistoryItemWithoutId = | HistoryItemMcpStatus | HistoryItemChatList | HistoryItemThinking - | HistoryItemHint - | HistoryItemHooksList; + | HistoryItemHint; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -414,7 +401,6 @@ export enum MessageType { AGENTS_LIST = 'agents_list', MCP_STATUS = 'mcp_status', CHAT_LIST = 'chat_list', - HOOKS_LIST = 'hooks_list', HINT = 'hint', } From b7a8f0d1f94a663c88563c37bb758ea29b8e61d5 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:12:13 -0500 Subject: [PATCH 12/21] fix(core): ensure subagents use qualified MCP tool names (#20801) --- .../core/src/agents/local-executor.test.ts | 22 ++++--- packages/core/src/agents/local-executor.ts | 20 +++---- packages/core/src/tools/tool-registry.test.ts | 58 ++++++++++++++++--- packages/core/src/tools/tool-registry.ts | 55 ++++++++++++++++-- 4 files changed, 121 insertions(+), 34 deletions(-) diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index df8755015c..5fb28d0e8a 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -501,7 +501,7 @@ describe('LocalAgentExecutor', () => { expect(agentRegistry.getTool(subAgentName)).toBeUndefined(); }); - it('should enforce qualified names for MCP tools in agent definitions', async () => { + it('should automatically qualify MCP tools in agent definitions', async () => { const serverName = 'mcp-server'; const toolName = 'mcp-tool'; const qualifiedName = `${serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}`; @@ -530,7 +530,7 @@ describe('LocalAgentExecutor', () => { return undefined; }); - // 1. Qualified name works and registers the tool (using short name per status quo) + // 1. Qualified name works and registers the tool (using qualified name) const definition = createTestDefinition([qualifiedName]); const executor = await LocalAgentExecutor.create( definition, @@ -539,14 +539,18 @@ describe('LocalAgentExecutor', () => { ); const agentRegistry = executor['toolRegistry']; - // Registry shortening logic means it's registered as 'mcp-tool' internally - expect(agentRegistry.getTool(toolName)).toBeDefined(); + // It should be registered as the qualified name + expect(agentRegistry.getTool(qualifiedName)).toBeDefined(); - // 2. Unqualified name for MCP tool THROWS - const badDefinition = createTestDefinition([toolName]); - await expect( - LocalAgentExecutor.create(badDefinition, mockConfig, onActivity), - ).rejects.toThrow(/must be requested with its server prefix/); + // 2. Unqualified name for MCP tool now also works (and gets upgraded to qualified) + const definition2 = createTestDefinition([toolName]); + const executor2 = await LocalAgentExecutor.create( + definition2, + mockConfig, + onActivity, + ); + const agentRegistry2 = executor2['toolRegistry']; + expect(agentRegistry2.getTool(qualifiedName)).toBeDefined(); getToolSpy.mockRestore(); }); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index 47217213f7..44616d29fa 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -16,10 +16,7 @@ import type { Schema, } from '@google/genai'; import { ToolRegistry } from '../tools/tool-registry.js'; -import { - DiscoveredMCPTool, - MCP_QUALIFIED_NAME_SEPARATOR, -} from '../tools/mcp-tool.js'; +import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { CompressionStatus } from '../core/turn.js'; import { type ToolCallRequestInfo } from '../scheduler/types.js'; import { ChatCompressionService } from '../services/chatCompressionService.js'; @@ -142,15 +139,14 @@ export class LocalAgentExecutor { // registry and register it with the agent's isolated registry. const tool = parentToolRegistry.getTool(toolName); if (tool) { - if ( - tool instanceof DiscoveredMCPTool && - !toolName.includes(MCP_QUALIFIED_NAME_SEPARATOR) - ) { - throw new Error( - `MCP tool '${toolName}' must be requested with its server prefix (e.g., '${tool.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}${toolName}') in agent '${definition.name}'.`, - ); + if (tool instanceof DiscoveredMCPTool) { + // Subagents MUST use fully qualified names for MCP tools to ensure + // unambiguous tool calls and to comply with policy requirements. + // We automatically "upgrade" any MCP tool to its qualified version. + agentToolRegistry.registerTool(tool.asFullyQualifiedTool()); + } else { + agentToolRegistry.registerTool(tool); } - agentToolRegistry.registerTool(tool); } }; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 57c992f674..d44c133705 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -380,20 +380,36 @@ describe('ToolRegistry', () => { }); describe('getAllToolNames', () => { - it('should return all registered tool names', () => { + it('should return all registered tool names with qualified names for MCP tools', () => { // Register tools with displayNames in non-alphabetical order const toolC = new MockTool({ name: 'c-tool', displayName: 'Tool C' }); const toolA = new MockTool({ name: 'a-tool', displayName: 'Tool A' }); - const toolB = new MockTool({ name: 'b-tool', displayName: 'Tool B' }); + const mcpTool = createMCPTool('my-server', 'my-tool', 'desc'); toolRegistry.registerTool(toolC); toolRegistry.registerTool(toolA); - toolRegistry.registerTool(toolB); + toolRegistry.registerTool(mcpTool); const toolNames = toolRegistry.getAllToolNames(); - // Assert that the returned array contains all tool names - expect(toolNames).toEqual(['c-tool', 'a-tool', 'b-tool']); + // Assert that the returned array contains all tool names, with MCP qualified + expect(toolNames).toContain('c-tool'); + expect(toolNames).toContain('a-tool'); + expect(toolNames).toContain('my-server__my-tool'); + expect(toolNames).toHaveLength(3); + }); + + it('should deduplicate tool names', () => { + const serverName = 'my-server'; + const toolName = 'my-tool'; + const mcpTool = createMCPTool(serverName, toolName, 'desc'); + + // Register same MCP tool twice (one as alias, one as qualified) + toolRegistry.registerTool(mcpTool); + toolRegistry.registerTool(mcpTool.asFullyQualifiedTool()); + + const toolNames = toolRegistry.getAllToolNames(); + expect(toolNames).toEqual([`${serverName}__${toolName}`]); }); }); @@ -465,8 +481,8 @@ describe('ToolRegistry', () => { 'builtin-1', 'builtin-2', DISCOVERED_TOOL_PREFIX + 'discovered-1', - 'mcp-apple', - 'mcp-zebra', + 'apple-server__mcp-apple', + 'zebra-server__mcp-zebra', ]); }); }); @@ -659,6 +675,34 @@ describe('ToolRegistry', () => { }); }); + describe('getFunctionDeclarations', () => { + it('should use fully qualified names for MCP tools in declarations', () => { + const serverName = 'my-server'; + const toolName = 'my-tool'; + const mcpTool = createMCPTool(serverName, toolName, 'description'); + + toolRegistry.registerTool(mcpTool); + + const declarations = toolRegistry.getFunctionDeclarations(); + expect(declarations).toHaveLength(1); + expect(declarations[0].name).toBe(`${serverName}__${toolName}`); + }); + + it('should deduplicate MCP tools in declarations', () => { + const serverName = 'my-server'; + const toolName = 'my-tool'; + const mcpTool = createMCPTool(serverName, toolName, 'description'); + + // Register both alias and qualified + toolRegistry.registerTool(mcpTool); + toolRegistry.registerTool(mcpTool.asFullyQualifiedTool()); + + const declarations = toolRegistry.getFunctionDeclarations(); + expect(declarations).toHaveLength(1); + expect(declarations[0].name).toBe(`${serverName}__${toolName}`); + }); + }); + describe('plan mode', () => { it('should only return policy-allowed tools in plan mode', () => { // Register several tools diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 7270f470ab..e7fd7a6a66 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -539,11 +539,32 @@ export class ToolRegistry { const plansDir = this.config.storage.getPlansDir(); const declarations: FunctionDeclaration[] = []; + const seenNames = new Set(); + this.getActiveTools().forEach((tool) => { + const toolName = + tool instanceof DiscoveredMCPTool + ? tool.getFullyQualifiedName() + : tool.name; + + if (seenNames.has(toolName)) { + return; + } + seenNames.add(toolName); + let schema = tool.getSchema(modelId); + + // Ensure the schema name matches the qualified name for MCP tools + if (tool instanceof DiscoveredMCPTool) { + schema = { + ...schema, + name: toolName, + }; + } + if ( isPlanMode && - (tool.name === WRITE_FILE_TOOL_NAME || tool.name === EDIT_TOOL_NAME) + (toolName === WRITE_FILE_TOOL_NAME || toolName === EDIT_TOOL_NAME) ) { schema = { ...schema, @@ -576,20 +597,42 @@ export class ToolRegistry { } /** - * Returns an array of all registered and discovered tool names which are not - * excluded via configuration. + * Returns an array of names for all active tools. + * For MCP tools, this returns their fully qualified names. + * The list is deduplicated. */ getAllToolNames(): string[] { - return this.getActiveTools().map((tool) => tool.name); + const names = new Set(); + for (const tool of this.getActiveTools()) { + if (tool instanceof DiscoveredMCPTool) { + names.add(tool.getFullyQualifiedName()); + } else { + names.add(tool.name); + } + } + return Array.from(names); } /** * Returns an array of all registered and discovered tool instances. */ getAllTools(): AnyDeclarativeTool[] { - return this.getActiveTools().sort((a, b) => + const seen = new Set(); + const tools: AnyDeclarativeTool[] = []; + + for (const tool of this.getActiveTools().sort((a, b) => a.displayName.localeCompare(b.displayName), - ); + )) { + const name = + tool instanceof DiscoveredMCPTool + ? tool.getFullyQualifiedName() + : tool.name; + if (!seen.has(name)) { + seen.add(name); + tools.push(tool); + } + } + return tools; } /** From 31ca57ec94b6047556e95ed14c3adafa1958d143 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Mon, 2 Mar 2026 13:12:17 -0800 Subject: [PATCH 13/21] feat: redesign header to be compact with ASCII icon (#18713) Co-authored-by: Jacob Richman --- packages/cli/src/test-utils/render.tsx | 6 +- .../src/ui/__snapshots__/App.test.tsx.snap | 85 +++++------ .../cli/src/ui/components/AppHeader.test.tsx | 7 + packages/cli/src/ui/components/AppHeader.tsx | 93 +++++++++--- packages/cli/src/ui/components/Tips.test.tsx | 32 ++-- packages/cli/src/ui/components/Tips.tsx | 32 ++-- .../src/ui/components/UserIdentity.test.tsx | 21 +-- .../cli/src/ui/components/UserIdentity.tsx | 45 +++--- ...ternateBufferQuittingDisplay.test.tsx.snap | 126 +++++++--------- .../__snapshots__/AppHeader.test.tsx.snap | 84 +++++------ .../__snapshots__/Tips.test.tsx.snap | 20 +++ ...-search-dialog-google_web_search-.snap.svg | 142 +++--------------- ...der-SVG-snapshot-for-a-shell-tool.snap.svg | 142 +++--------------- ...pty-slice-following-a-search-tool.snap.svg | 142 +++--------------- .../__snapshots__/borderStyles.test.tsx.snap | 36 ++--- 15 files changed, 382 insertions(+), 631 deletions(-) create mode 100644 packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 6908fd36fb..71724285d2 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -528,12 +528,13 @@ export const mockSettings = new LoadedSettings( // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. const baseMockUiState = { + history: [], renderMarkdown: true, streamingState: StreamingState.Idle, terminalWidth: 100, terminalHeight: 40, currentModel: 'gemini-pro', - terminalBackgroundColor: 'black', + terminalBackgroundColor: 'black' as const, cleanUiDetailsVisible: false, allowPlanMode: true, activePtyId: undefined, @@ -552,6 +553,9 @@ const baseMockUiState = { warningText: '', }, bannerVisible: false, + nightly: false, + updateInfo: null, + pendingHistoryItems: [], }; export const mockAppState: AppState = { diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 450da8362e..9e1d66df01 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -2,20 +2,20 @@ exports[`App > Snapshots > renders default layout correctly 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results + + + @@ -47,34 +47,31 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` "Notifications Footer - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results Composer " `; exports[`App > Snapshots > renders with dialogs visible 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + + + + @@ -110,20 +107,17 @@ DialogManager exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results HistoryItemDisplay ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Action Required │ @@ -146,6 +140,9 @@ HistoryItemDisplay + + + Notifications Composer " diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 9bf821febc..ebcd4de973 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -213,6 +213,12 @@ describe('', () => { it('should NOT render Tips when tipsShown is 10 or more', async () => { const mockConfig = makeFakeConfig(); + const uiState = { + bannerData: { + defaultText: '', + warningText: '', + }, + }; persistentStateMock.setData({ tipsShown: 10 }); @@ -220,6 +226,7 @@ describe('', () => { , { config: mockConfig, + uiState, }, ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index ad5e2f67d2..b9601e772a 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -1,58 +1,113 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { Box } from 'ink'; -import { Header } from './Header.js'; -import { Tips } from './Tips.js'; +import { Box, Text } from 'ink'; import { UserIdentity } from './UserIdentity.js'; +import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { Banner } from './Banner.js'; import { useBanner } from '../hooks/useBanner.js'; import { useTips } from '../hooks/useTips.js'; +import { theme } from '../semantic-colors.js'; +import { ThemedGradient } from './ThemedGradient.js'; +import { CliSpinner } from './CliSpinner.js'; interface AppHeaderProps { version: string; showDetails?: boolean; } +const ICON = `▝▜▄ + ▝▜▄ + ▗▟▀ +▝▀ `; + export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => { const settings = useSettings(); const config = useConfig(); - const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState(); + const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState(); const { bannerText } = useBanner(bannerData); const { showTips } = useTips(); + const showHeader = !( + settings.merged.ui.hideBanner || config.getScreenReader() + ); + if (!showDetails) { return ( -
+ {showHeader && ( + + + {ICON} + + + + + Gemini CLI + + v{version} + + + + )} ); } return ( - {!(settings.merged.ui.hideBanner || config.getScreenReader()) && ( - <> -
- {bannerVisible && bannerText && ( - - )} - + {showHeader && ( + + + {ICON} + + + {/* Line 1: Gemini CLI vVersion [Updating] */} + + + Gemini CLI + + v{version} + {updateInfo && ( + + + Updating + + + )} + + + {/* Line 2: Blank */} + + + {/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */} + {settings.merged.ui.showUserIdentity !== false && ( + + )} + + )} - {settings.merged.ui.showUserIdentity !== false && ( - + + {bannerVisible && bannerText && ( + )} + {!(settings.merged.ui.hideTips || config.getScreenReader()) && showTips && } diff --git a/packages/cli/src/ui/components/Tips.test.tsx b/packages/cli/src/ui/components/Tips.test.tsx index 06b4760834..873230fb87 100644 --- a/packages/cli/src/ui/components/Tips.test.tsx +++ b/packages/cli/src/ui/components/Tips.test.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -11,22 +11,18 @@ import type { Config } from '@google/gemini-cli-core'; describe('Tips', () => { it.each([ - [0, '3. Create GEMINI.md files'], - [5, '3. /help for more information'], - ])( - 'renders correct tips when file count is %i', - async (count, expectedText) => { - const config = { - getGeminiMdFileCount: vi.fn().mockReturnValue(count), - } as unknown as Config; + { fileCount: 0, description: 'renders all tips including GEMINI.md tip' }, + { fileCount: 5, description: 'renders fewer tips when GEMINI.md exists' }, + ])('$description', async ({ fileCount }) => { + const config = { + getGeminiMdFileCount: vi.fn().mockReturnValue(fileCount), + } as unknown as Config; - const { lastFrame, waitUntilReady, unmount } = render( - , - ); - await waitUntilReady(); - const output = lastFrame(); - expect(output).toContain(expectedText); - unmount(); - }, - ); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx index 576b8494c5..8ac6f33bf8 100644 --- a/packages/cli/src/ui/components/Tips.tsx +++ b/packages/cli/src/ui/components/Tips.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -15,30 +15,26 @@ interface TipsProps { export const Tips: React.FC = ({ config }) => { const geminiMdFileCount = config.getGeminiMdFileCount(); + return ( - + Tips for getting started: - - 1. Ask questions, edit files, or run commands. - - - 2. Be specific for the best results. - {geminiMdFileCount === 0 && ( - 3. Create{' '} - - GEMINI.md - {' '} - files to customize your interactions with Gemini. + 1. Create GEMINI.md files to customize your + interactions )} - {geminiMdFileCount === 0 ? '4.' : '3.'}{' '} - - /help - {' '} - for more information. + {geminiMdFileCount === 0 ? '2.' : '1.'}{' '} + /help for more information + + + {geminiMdFileCount === 0 ? '3.' : '2.'} Ask coding questions, edit code + or run commands + + + {geminiMdFileCount === 0 ? '4.' : '3.'} Be specific for the best results ); diff --git a/packages/cli/src/ui/components/UserIdentity.test.tsx b/packages/cli/src/ui/components/UserIdentity.test.tsx index a5b41f4b61..8e63415f5c 100644 --- a/packages/cli/src/ui/components/UserIdentity.test.tsx +++ b/packages/cli/src/ui/components/UserIdentity.test.tsx @@ -45,12 +45,12 @@ describe('', () => { await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('Logged in with Google: test@example.com'); + expect(output).toContain('test@example.com'); expect(output).toContain('/auth'); unmount(); }); - it('should render login message without colon if email is missing', async () => { + it('should render login message if email is missing', async () => { // Modify the mock for this specific test vi.mocked(UserAccountManager).mockImplementationOnce( () => @@ -73,12 +73,11 @@ describe('', () => { const output = lastFrame(); expect(output).toContain('Logged in with Google'); - expect(output).not.toContain('Logged in with Google:'); expect(output).toContain('/auth'); unmount(); }); - it('should render plan name on a separate line if provided', async () => { + it('should render plan name and upgrade indicator', async () => { const mockConfig = makeFakeConfig(); vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({ authType: AuthType.LOGIN_WITH_GOOGLE, @@ -92,18 +91,10 @@ describe('', () => { await waitUntilReady(); const output = lastFrame(); - expect(output).toContain('Logged in with Google: test@example.com'); + expect(output).toContain('test@example.com'); expect(output).toContain('/auth'); - expect(output).toContain('Plan: Premium Plan'); - - // Check for two lines (or more if wrapped, but here it should be separate) - const lines = output?.split('\n').filter((line) => line.trim().length > 0); - expect(lines?.some((line) => line.includes('Logged in with Google'))).toBe( - true, - ); - expect(lines?.some((line) => line.includes('Plan: Premium Plan'))).toBe( - true, - ); + expect(output).toContain('Premium Plan'); + expect(output).toContain('/upgrade'); unmount(); }); diff --git a/packages/cli/src/ui/components/UserIdentity.tsx b/packages/cli/src/ui/components/UserIdentity.tsx index e506bfb052..08c82573d9 100644 --- a/packages/cli/src/ui/components/UserIdentity.tsx +++ b/packages/cli/src/ui/components/UserIdentity.tsx @@ -1,11 +1,11 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; -import { useMemo } from 'react'; +import { useMemo, useEffect, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { @@ -20,42 +20,45 @@ interface UserIdentityProps { export const UserIdentity: React.FC = ({ config }) => { const authType = config.getContentGeneratorConfig()?.authType; + const [email, setEmail] = useState(); - const { email, tierName } = useMemo(() => { - if (!authType) { - return { email: undefined, tierName: undefined }; + useEffect(() => { + if (authType) { + const userAccountManager = new UserAccountManager(); + setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined); } - const userAccountManager = new UserAccountManager(); - return { - email: userAccountManager.getCachedGoogleAccount(), - tierName: config.getUserTierName(), - }; - }, [config, authType]); + }, [authType]); + + const tierName = useMemo( + () => (authType ? config.getUserTierName() : undefined), + [config, authType], + ); if (!authType) { return null; } return ( - + + {/* User Email /auth */} - + {authType === AuthType.LOGIN_WITH_GOOGLE ? ( - - Logged in with Google{email ? ':' : ''} - {email ? ` ${email}` : ''} - + {email ?? 'Logged in with Google'} ) : ( `Authenticated with ${authType}` )} /auth - {tierName && ( - - Plan: {tierName} + + {/* Tier Name /upgrade */} + + + {tierName ?? 'Gemini Code Assist for individuals'} - )} + /upgrade + ); }; diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap index 18e75b75e2..ec8712ebc1 100644 --- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -2,20 +2,17 @@ exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v0.10.0 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results Action Required (was prompted): @@ -25,20 +22,17 @@ Action Required (was prompted): exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v0.10.0 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool1 Description for tool 1 │ │ │ @@ -52,39 +46,33 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v0.10.0 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results " `; exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v0.10.0 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool1 Description for tool 1 │ │ │ @@ -98,39 +86,33 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v0.10.0 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results " `; exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v0.10.0 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Hello Gemini ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap index 324274fddd..4411f766de 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap @@ -2,82 +2,70 @@ exports[` > should not render the banner when no flags are set 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.0.0 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results " `; exports[` > should not render the default banner if shown count is 5 or more 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.0.0 + ▝▜▄ + ▗▟▀ + ▝▀ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results " `; exports[` > should render the banner with default text 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.0.0 + ▝▜▄ + ▗▟▀ + ▝▀ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ This is the default banner │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results " `; exports[` > should render the banner with warning text 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.0.0 + ▝▜▄ + ▗▟▀ + ▝▀ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ There are capacity issues │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + Tips for getting started: -1. Ask questions, edit files, or run commands. -2. Be specific for the best results. -3. Create GEMINI.md files to customize your interactions with Gemini. -4. /help for more information. +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results " `; diff --git a/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap new file mode 100644 index 0000000000..dbc60fcf4d --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Tips.test.tsx.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Tips > 'renders all tips including GEMINI.md …' 1`] = ` +" +Tips for getting started: +1. Create GEMINI.md files to customize your interactions +2. /help for more information +3. Ask coding questions, edit code or run commands +4. Be specific for the best results +" +`; + +exports[`Tips > 'renders fewer tips when GEMINI.md exi…' 1`] = ` +" +Tips for getting started: +1. /help for more information +2. Ask coding questions, edit code or run commands +3. Be specific for the best results +" +`; diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg index b9290efcac..280f558d63 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-pending-search-dialog-google_web_search-.snap.svg @@ -1,123 +1,31 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ - - ⊷ google_web_search - - - - - Searching... - - ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + Gemini CLI + v1.2.3 + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + ⊷ google_web_search + + + + + Searching... + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg index 0ba0125a62..3dddced46d 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-a-shell-tool.snap.svg @@ -1,123 +1,31 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ - - ⊷ run_shell_command - - - - - Running command... - - ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + Gemini CLI + v1.2.3 + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + ⊷ run_shell_command + + + + + Running command... + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg index b9290efcac..280f558d63 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles-MainContent-tool-group-border-SVG-snapshots-should-render-SVG-snapshot-for-an-empty-slice-following-a-search-tool.snap.svg @@ -1,123 +1,31 @@ - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ - - ⊷ google_web_search - - - - - Searching... - - ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + Gemini CLI + v1.2.3 + + + + + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + ⊷ google_web_search + + + + + Searching... + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap index fbdc559480..d34d820236 100644 --- a/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/borderStyles.test.tsx.snap @@ -2,14 +2,10 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a pending search dialog (google_web_search) 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ google_web_search │ @@ -20,14 +16,10 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ run_shell_command │ @@ -38,14 +30,10 @@ exports[`MainContent tool group border SVG snapshots > should render SVG snapsho exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = ` " - ███ █████████ -░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ - ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ▝▜▄ Gemini CLI v1.2.3 + ▝▜▄ + ▗▟▀ + ▝▀ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ google_web_search │ From 18d0375a7fb25b9f14252df98be68c57c0fa4351 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 2 Mar 2026 13:29:31 -0800 Subject: [PATCH 14/21] feat(core): support authenticated A2A agent card discovery (#20622) Co-authored-by: Adam Weidman Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> --- packages/core/src/agents/registry.test.ts | 24 +++++++++++ packages/core/src/agents/registry.ts | 52 ++++++++++++++++++++--- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 7c856e4089..edae478f2a 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -664,6 +664,30 @@ describe('AgentRegistry', () => { ); }); + it('should surface an error if remote agent registration fails', async () => { + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'FailingRemoteAgent', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + }; + + const error = new Error('401 Unauthorized'); + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: vi.fn().mockRejectedValue(error), + } as unknown as A2AClientManager); + + const feedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + + await registry.testRegisterAgent(remoteAgent); + + expect(feedbackSpy).toHaveBeenCalledWith( + 'error', + `Error loading A2A agent "FailingRemoteAgent": 401 Unauthorized`, + ); + }); + it('should merge user and agent description and skills when registering a remote agent', async () => { const remoteAgent: AgentDefinition = { kind: 'remote', diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index d9de43eb63..bf7e669150 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -119,7 +119,20 @@ export class AgentRegistry { coreEvents.emitFeedback('error', `Agent loading error: ${error.message}`); } await Promise.allSettled( - userAgents.agents.map((agent) => this.registerAgent(agent)), + userAgents.agents.map(async (agent) => { + try { + await this.registerAgent(agent); + } catch (e) { + debugLogger.warn( + `[AgentRegistry] Error registering user agent "${agent.name}":`, + e, + ); + coreEvents.emitFeedback( + 'error', + `Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + }), ); // Load project-level agents: .gemini/agents/ (relative to Project Root) @@ -174,7 +187,20 @@ export class AgentRegistry { } await Promise.allSettled( - agentsToRegister.map((agent) => this.registerAgent(agent)), + agentsToRegister.map(async (agent) => { + try { + await this.registerAgent(agent); + } catch (e) { + debugLogger.warn( + `[AgentRegistry] Error registering project agent "${agent.name}":`, + e, + ); + coreEvents.emitFeedback( + 'error', + `Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + }), ); } else { coreEvents.emitFeedback( @@ -187,7 +213,20 @@ export class AgentRegistry { for (const extension of this.config.getExtensions()) { if (extension.isActive && extension.agents) { await Promise.allSettled( - extension.agents.map((agent) => this.registerAgent(agent)), + extension.agents.map(async (agent) => { + try { + await this.registerAgent(agent); + } catch (e) { + debugLogger.warn( + `[AgentRegistry] Error registering extension agent "${agent.name}":`, + e, + ); + coreEvents.emitFeedback( + 'error', + `Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`, + ); + } + }), ); } } @@ -424,10 +463,9 @@ export class AgentRegistry { this.agents.set(definition.name, definition); this.addAgentPolicy(definition); } catch (e) { - debugLogger.warn( - `[AgentRegistry] Error loading A2A agent "${definition.name}":`, - e, - ); + const errorMessage = `Error loading A2A agent "${definition.name}": ${e instanceof Error ? e.message : String(e)}`; + debugLogger.warn(`[AgentRegistry] ${errorMessage}`, e); + coreEvents.emitFeedback('error', errorMessage); } } From 8133d63ac691d08b065199ed9c957e911383d9dd Mon Sep 17 00:00:00 2001 From: Pyush Sinha Date: Mon, 2 Mar 2026 13:30:58 -0800 Subject: [PATCH 15/21] refactor(cli): fully remove React anti patterns, improve type safety and fix UX oversights in SettingsDialog.tsx (#18963) Co-authored-by: Jacob Richman --- packages/cli/src/gemini.tsx | 2 +- packages/cli/src/test-utils/render.tsx | 2 +- .../src/ui/components/AgentConfigDialog.tsx | 67 +-- .../cli/src/ui/components/DialogManager.tsx | 2 - .../src/ui/components/SettingsDialog.test.tsx | 377 ++++++------- .../cli/src/ui/components/SettingsDialog.tsx | 486 +++++------------ .../shared/BaseSettingsDialog.test.tsx | 31 ++ .../components/shared/BaseSettingsDialog.tsx | 16 +- .../cli/src/ui/contexts/SettingsContext.tsx | 3 +- .../cli/src/ui/contexts/VimModeContext.tsx | 31 +- .../cli/src/utils/dialogScopeUtils.test.ts | 10 +- packages/cli/src/utils/dialogScopeUtils.ts | 15 +- packages/cli/src/utils/settingsUtils.test.ts | 505 ++---------------- packages/cli/src/utils/settingsUtils.ts | 432 ++++++--------- 14 files changed, 589 insertions(+), 1390 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2e238765e8..88f9f404cd 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -243,7 +243,7 @@ export async function startInteractiveUI( - + - + void; } -/** - * Get a nested value from an object using a path array - */ -function getNestedValue( - obj: Record | undefined, - path: string[], -): unknown { - if (!obj) return undefined; - let current: unknown = obj; - for (const key of path) { - if (current === null || current === undefined) return undefined; - if (typeof current !== 'object') return undefined; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current = (current as Record)[key]; - } - return current; -} - /** * Set a nested value in an object using a path array, creating intermediate objects as needed */ -function setNestedValue( - obj: Record, - path: string[], - value: unknown, -): Record { +function setNestedValue(obj: unknown, path: string[], value: unknown): unknown { + if (!isRecord(obj)) return obj; + const result = { ...obj }; let current = result; @@ -144,12 +125,17 @@ function setNestedValue( const key = path[i]; if (current[key] === undefined || current[key] === null) { current[key] = {}; - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current[key] = { ...(current[key] as Record) }; + } else if (isRecord(current[key])) { + current[key] = { ...current[key] }; + } + + const next = current[key]; + if (isRecord(next)) { + current = next; + } else { + // Cannot traverse further through non-objects + return result; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - current = current[key] as Record; } const finalKey = path[path.length - 1]; @@ -267,11 +253,7 @@ export function AgentConfigDialog({ const items: SettingsDialogItem[] = useMemo( () => AGENT_CONFIG_FIELDS.map((field) => { - const currentValue = getNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, - field.path, - ); + const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; @@ -324,23 +306,18 @@ export function AgentConfigDialog({ const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key); if (!field || field.type !== 'boolean') return; - const currentValue = getNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, - field.path, - ); + const currentValue = getNestedValue(pendingOverride, field.path); const defaultValue = getFieldDefaultFromDefinition(field, definition); const effectiveValue = currentValue !== undefined ? currentValue : defaultValue; const newValue = !effectiveValue; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, newValue, ) as AgentOverride; - setPendingOverride(newOverride); setModifiedFields((prev) => new Set(prev).add(key)); @@ -375,9 +352,9 @@ export function AgentConfigDialog({ } // Update pending override locally + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, parsed, ) as AgentOverride; @@ -398,9 +375,9 @@ export function AgentConfigDialog({ if (!field) return; // Remove the override (set to undefined) + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const newOverride = setNestedValue( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - pendingOverride as Record, + pendingOverride, field.path, undefined, ) as AgentOverride; diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f7f050a53f..3cca19b0b0 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -281,14 +281,12 @@ export const DialogManager = ({ return ( uiActions.closeSettingsDialog()} onRestartRequest={async () => { await runExitCleanup(); process.exit(RELAUNCH_EXIT_CODE); }} availableTerminalHeight={terminalHeight - staticExtraHeight} - config={config} /> ); diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 3dd5374a18..be99dfcc26 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -14,7 +14,6 @@ * - Focus section switching between settings and scope selector * - Scope selection and settings persistence across scopes * - Restart-required vs immediate settings behavior - * - VimModeContext integration * - Complex user interaction workflows * - Error handling and edge cases * - Display values for inherited and overridden settings @@ -25,12 +24,12 @@ import { render } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsDialog } from './SettingsDialog.js'; -import { LoadedSettings, SettingScope } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; import { createMockSettings } from '../../test-utils/settings.js'; -import { VimModeProvider } from '../contexts/VimModeContext.js'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; -import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js'; +import { TEST_ONLY } from '../../utils/settingsUtils.js'; +import { SettingsContext } from '../contexts/SettingsContext.js'; import { getSettingsSchema, type SettingDefinition, @@ -38,10 +37,6 @@ import { } from '../../config/settingsSchema.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; -// Mock the VimModeContext -const mockToggleVimEnabled = vi.fn().mockResolvedValue(undefined); -const mockSetVimMode = vi.fn(); - vi.mock('../contexts/UIStateContext.js', () => ({ useUIState: () => ({ terminalWidth: 100, // Fixed width for consistent snapshots @@ -68,27 +63,6 @@ vi.mock('../../config/settingsSchema.js', async (importOriginal) => { }; }); -vi.mock('../contexts/VimModeContext.js', async () => { - const actual = await vi.importActual('../contexts/VimModeContext.js'); - return { - ...actual, - useVimMode: () => ({ - vimEnabled: false, - vimMode: 'INSERT' as const, - toggleVimEnabled: mockToggleVimEnabled, - setVimMode: mockSetVimMode, - }), - }; -}); - -vi.mock('../../utils/settingsUtils.js', async () => { - const actual = await vi.importActual('../../utils/settingsUtils.js'); - return { - ...actual, - saveModifiedSettings: vi.fn(), - }; -}); - // Shared test schemas enum StringEnum { FOO = 'foo', @@ -131,6 +105,62 @@ const ENUM_FAKE_SCHEMA: SettingsSchemaType = { }, } as unknown as SettingsSchemaType; +const ARRAY_FAKE_SCHEMA: SettingsSchemaType = { + context: { + type: 'object', + label: 'Context', + category: 'Context', + requiresRestart: false, + default: {}, + description: 'Context settings.', + showInDialog: false, + properties: { + fileFiltering: { + type: 'object', + label: 'File Filtering', + category: 'Context', + requiresRestart: false, + default: {}, + description: 'File filtering settings.', + showInDialog: false, + properties: { + customIgnoreFilePaths: { + type: 'array', + label: 'Custom Ignore File Paths', + category: 'Context', + requiresRestart: false, + default: [] as string[], + description: 'Additional ignore file paths.', + showInDialog: true, + items: { type: 'string' }, + }, + }, + }, + }, + }, + security: { + type: 'object', + label: 'Security', + category: 'Security', + requiresRestart: false, + default: {}, + description: 'Security settings.', + showInDialog: false, + properties: { + allowedExtensions: { + type: 'array', + label: 'Extension Source Regex Allowlist', + category: 'Security', + requiresRestart: false, + default: [] as string[], + description: 'Allowed extension source regex patterns.', + showInDialog: true, + items: { type: 'string' }, + }, + }, + }, +} as unknown as SettingsSchemaType; + const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = { tools: { type: 'object', @@ -185,7 +215,7 @@ const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = { // Helper function to render SettingsDialog with standard wrapper const renderDialog = ( - settings: LoadedSettings, + settings: ReturnType, onSelect: ReturnType, options?: { onRestartRequest?: ReturnType; @@ -193,14 +223,15 @@ const renderDialog = ( }, ) => render( - - - , + + + + + , ); describe('SettingsDialog', () => { @@ -210,7 +241,6 @@ describe('SettingsDialog', () => { terminalCapabilityManager, 'isKittyProtocolEnabled', ).mockReturnValue(true); - mockToggleVimEnabled.mockRejectedValue(undefined); }); afterEach(() => { @@ -394,9 +424,8 @@ describe('SettingsDialog', () => { describe('Settings Toggling', () => { it('should toggle setting with Enter key', async () => { - vi.mocked(saveModifiedSettings).mockClear(); - const settings = createMockSettings(); + const setValueSpy = vi.spyOn(settings, 'setValue'); const onSelect = vi.fn(); const { stdin, unmount, lastFrame, waitUntilReady } = renderDialog( @@ -414,29 +443,16 @@ describe('SettingsDialog', () => { await act(async () => { stdin.write(TerminalKeys.ENTER as string); }); - await waitUntilReady(); - // Wait for the setting change to be processed + // Wait for setValue to be called await waitFor(() => { - expect( - vi.mocked(saveModifiedSettings).mock.calls.length, - ).toBeGreaterThan(0); + expect(setValueSpy).toHaveBeenCalled(); }); - // Wait for the mock to be called - await waitFor(() => { - expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); - }); - - expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith( - new Set(['general.vimMode']), - expect.objectContaining({ - general: expect.objectContaining({ - vimMode: true, - }), - }), - expect.any(LoadedSettings), + expect(setValueSpy).toHaveBeenCalledWith( SettingScope.User, + 'general.vimMode', + true, ); unmount(); @@ -455,13 +471,13 @@ describe('SettingsDialog', () => { expectedValue: StringEnum.FOO, }, ])('$name', async ({ initialValue, expectedValue }) => { - vi.mocked(saveModifiedSettings).mockClear(); vi.mocked(getSettingsSchema).mockReturnValue(ENUM_FAKE_SCHEMA); const settings = createMockSettings(); if (initialValue !== undefined) { settings.setValue(SettingScope.User, 'ui.theme', initialValue); } + const setValueSpy = vi.spyOn(settings, 'setValue'); const onSelect = vi.fn(); @@ -482,20 +498,13 @@ describe('SettingsDialog', () => { await waitUntilReady(); await waitFor(() => { - expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'ui.theme', + expectedValue, + ); }); - expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalledWith( - new Set(['ui.theme']), - expect.objectContaining({ - ui: expect.objectContaining({ - theme: expectedValue, - }), - }), - expect.any(LoadedSettings), - SettingScope.User, - ); - unmount(); }); }); @@ -692,30 +701,6 @@ describe('SettingsDialog', () => { }); }); - describe('Error Handling', () => { - it('should handle vim mode toggle errors gracefully', async () => { - mockToggleVimEnabled.mockRejectedValue(new Error('Toggle failed')); - - const settings = createMockSettings(); - const onSelect = vi.fn(); - - const { stdin, unmount, waitUntilReady } = renderDialog( - settings, - onSelect, - ); - await waitUntilReady(); - - // Try to toggle a setting (this might trigger vim mode toggle) - await act(async () => { - stdin.write(TerminalKeys.ENTER as string); // Enter - }); - await waitUntilReady(); - - // Should not crash - unmount(); - }); - }); - describe('Complex State Management', () => { it('should track modified settings correctly', async () => { const settings = createMockSettings(); @@ -767,31 +752,6 @@ describe('SettingsDialog', () => { }); }); - describe('VimMode Integration', () => { - it('should sync with VimModeContext when vim mode is toggled', async () => { - const settings = createMockSettings(); - const onSelect = vi.fn(); - - const { stdin, unmount, waitUntilReady } = render( - - - - - , - ); - await waitUntilReady(); - - // Navigate to and toggle vim mode setting - // This would require knowing the exact position of vim mode setting - await act(async () => { - stdin.write(TerminalKeys.ENTER as string); // Enter - }); - await waitUntilReady(); - - unmount(); - }); - }); - describe('Specific Settings Behavior', () => { it('should show correct display values for settings with different states', async () => { const settings = createMockSettings({ @@ -861,7 +821,7 @@ describe('SettingsDialog', () => { // Should not show restart prompt initially await waitFor(() => { expect(lastFrame()).not.toContain( - 'To see changes, Gemini CLI must be restarted', + 'Changes that require a restart have been modified', ); }); @@ -957,63 +917,41 @@ describe('SettingsDialog', () => { pager: 'less', }, }, - ])( - 'should $name', - async ({ toggleCount, shellSettings, expectedSiblings }) => { - vi.mocked(saveModifiedSettings).mockClear(); + ])('should $name', async ({ toggleCount, shellSettings }) => { + vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA); - vi.mocked(getSettingsSchema).mockReturnValue(TOOLS_SHELL_FAKE_SCHEMA); + const settings = createMockSettings({ + tools: { + shell: shellSettings, + }, + }); + const setValueSpy = vi.spyOn(settings, 'setValue'); - const settings = createMockSettings({ - tools: { - shell: shellSettings, - }, + const onSelect = vi.fn(); + + const { stdin, unmount } = renderDialog(settings, onSelect); + + for (let i = 0; i < toggleCount; i++) { + act(() => { + stdin.write(TerminalKeys.ENTER as string); }); + } - const onSelect = vi.fn(); + await waitFor(() => { + expect(setValueSpy).toHaveBeenCalled(); + }); - const { stdin, unmount, waitUntilReady } = renderDialog( - settings, - onSelect, - ); - await waitUntilReady(); + // With the store pattern, setValue is called atomically per key. + // Sibling preservation is handled by LoadedSettings internally. + const calls = setValueSpy.mock.calls; + expect(calls.length).toBeGreaterThan(0); + calls.forEach((call) => { + // Each call should target only 'tools.shell.showColor' + expect(call[1]).toBe('tools.shell.showColor'); + }); - for (let i = 0; i < toggleCount; i++) { - await act(async () => { - stdin.write(TerminalKeys.ENTER as string); - }); - await waitUntilReady(); - } - - await waitFor(() => { - expect( - vi.mocked(saveModifiedSettings).mock.calls.length, - ).toBeGreaterThan(0); - }); - - const calls = vi.mocked(saveModifiedSettings).mock.calls; - calls.forEach((call) => { - const [modifiedKeys, pendingSettings] = call; - - if (modifiedKeys.has('tools.shell.showColor')) { - const shellSettings = pendingSettings.tools?.shell as - | Record - | undefined; - - Object.entries(expectedSiblings).forEach(([key, value]) => { - expect(shellSettings?.[key]).toBe(value); - expect(modifiedKeys.has(`tools.shell.${key}`)).toBe(false); - }); - - expect(modifiedKeys.size).toBe(1); - } - }); - - expect(calls.length).toBeGreaterThan(0); - - unmount(); - }, - ); + unmount(); + }); }); describe('Keyboard Shortcuts Edge Cases', () => { @@ -1319,7 +1257,7 @@ describe('SettingsDialog', () => { await waitFor(() => { expect(lastFrame()).toContain( - 'To see changes, Gemini CLI must be restarted', + 'Changes that require a restart have been modified', ); }); @@ -1366,7 +1304,7 @@ describe('SettingsDialog', () => { await waitFor(() => { expect(lastFrame()).toContain( - 'To see changes, Gemini CLI must be restarted', + 'Changes that require a restart have been modified', ); }); @@ -1385,9 +1323,11 @@ describe('SettingsDialog', () => { const onSelect = vi.fn(); const { stdin, unmount, rerender, waitUntilReady } = render( - - - , + + + + + , ); await waitUntilReady(); @@ -1424,14 +1364,13 @@ describe('SettingsDialog', () => { path: '', }, }); - await act(async () => { - rerender( + rerender( + - - , - ); - }); - await waitUntilReady(); + + + , + ); // Press Escape to exit await act(async () => { @@ -1447,6 +1386,74 @@ describe('SettingsDialog', () => { }); }); + describe('Array Settings Editing', () => { + const typeInput = async ( + stdin: { write: (data: string) => void }, + input: string, + ) => { + for (const ch of input) { + await act(async () => { + stdin.write(ch); + }); + } + }; + + it('should parse comma-separated input as string arrays', async () => { + vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA); + const settings = createMockSettings(); + const setValueSpy = vi.spyOn(settings, 'setValue'); + + const { stdin, unmount } = renderDialog(settings, vi.fn()); + + await act(async () => { + stdin.write(TerminalKeys.ENTER as string); // Start editing first array setting + }); + await typeInput(stdin, 'first/path, second/path,third/path'); + await act(async () => { + stdin.write(TerminalKeys.ENTER as string); // Commit + }); + + await waitFor(() => { + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'context.fileFiltering.customIgnoreFilePaths', + ['first/path', 'second/path', 'third/path'], + ); + }); + + unmount(); + }); + + it('should parse JSON array input for allowedExtensions', async () => { + vi.mocked(getSettingsSchema).mockReturnValue(ARRAY_FAKE_SCHEMA); + const settings = createMockSettings(); + const setValueSpy = vi.spyOn(settings, 'setValue'); + + const { stdin, unmount } = renderDialog(settings, vi.fn()); + + await act(async () => { + stdin.write(TerminalKeys.DOWN_ARROW as string); // Move to second array setting + }); + await act(async () => { + stdin.write(TerminalKeys.ENTER as string); // Start editing + }); + await typeInput(stdin, '["^github\\\\.com/.*$", "^gitlab\\\\.com/.*$"]'); + await act(async () => { + stdin.write(TerminalKeys.ENTER as string); // Commit + }); + + await waitFor(() => { + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'security.allowedExtensions', + ['^github\\.com/.*$', '^gitlab\\.com/.*$'], + ); + }); + + unmount(); + }); + }); + describe('Search Functionality', () => { it('should display text entered in search', async () => { const settings = createMockSettings(); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index e426e9bbe3..23e8a55a7d 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -5,40 +5,35 @@ */ import type React from 'react'; -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { Text } from 'ink'; import { AsyncFzf } from 'fzf'; import type { Key } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; -import type { - LoadableSettingScope, - LoadedSettings, - Settings, -} from '../../config/settings.js'; +import type { LoadableSettingScope, Settings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { getDialogSettingKeys, - setPendingSettingValue, getDisplayValue, - hasRestartRequiredSettings, - saveModifiedSettings, getSettingDefinition, - isDefaultValue, - requiresRestart, - getRestartRequiredFromModified, - getEffectiveDefaultValue, - setPendingSettingValueAny, + getDialogRestartRequiredSettings, getEffectiveValue, + isInSettingsScope, + getEditValue, + parseEditedValue, } from '../../utils/settingsUtils.js'; -import { useVimMode } from '../contexts/VimModeContext.js'; +import { + useSettingsStore, + type SettingsState, +} from '../contexts/SettingsContext.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { + type SettingsType, type SettingsValue, TOGGLE_TYPES, } from '../../config/settingsSchema.js'; -import { coreEvents, debugLogger } from '@google/gemini-cli-core'; -import type { Config } from '@google/gemini-cli-core'; +import { debugLogger } from '@google/gemini-cli-core'; import { useSearchBuffer } from '../hooks/useSearchBuffer.js'; import { @@ -55,31 +50,56 @@ interface FzfResult { } interface SettingsDialogProps { - settings: LoadedSettings; onSelect: (settingName: string | undefined, scope: SettingScope) => void; onRestartRequest?: () => void; availableTerminalHeight?: number; - config?: Config; } const MAX_ITEMS_TO_SHOW = 8; +// Create a snapshot of the initial per-scope state of Restart Required Settings +// This creates a nested map of the form +// restartRequiredSetting -> Map { scopeName -> value } +function getActiveRestartRequiredSettings( + settings: SettingsState, +): Map> { + const snapshot = new Map>(); + const scopes: Array<[string, Settings]> = [ + ['User', settings.user.settings], + ['Workspace', settings.workspace.settings], + ['System', settings.system.settings], + ]; + + for (const key of getDialogRestartRequiredSettings()) { + const scopeMap = new Map(); + for (const [scopeName, scopeSettings] of scopes) { + // Raw per-scope value (undefined if not in file) + const value = isInSettingsScope(key, scopeSettings) + ? getEffectiveValue(key, scopeSettings) + : undefined; + scopeMap.set(scopeName, JSON.stringify(value)); + } + snapshot.set(key, scopeMap); + } + return snapshot; +} + export function SettingsDialog({ - settings, onSelect, onRestartRequest, availableTerminalHeight, - config, }: SettingsDialogProps): React.JSX.Element { - // Get vim mode context to sync vim mode changes - const { vimEnabled, toggleVimEnabled } = useVimMode(); + // Reactive settings from store (re-renders on any settings change) + const { settings, setSetting } = useSettingsStore(); - // Scope selector state (User by default) const [selectedScope, setSelectedScope] = useState( SettingScope.User, ); - const [showRestartPrompt, setShowRestartPrompt] = useState(false); + // Snapshot restart-required values at mount time for diff tracking + const [activeRestartRequiredSettings] = useState(() => + getActiveRestartRequiredSettings(settings), + ); // Search state const [searchQuery, setSearchQuery] = useState(''); @@ -136,52 +156,34 @@ export function SettingsDialog({ }; }, [searchQuery, fzfInstance, searchMap]); - // Local pending settings state for the selected scope - const [pendingSettings, setPendingSettings] = useState(() => - // Deep clone to avoid mutation - structuredClone(settings.forScope(selectedScope).settings), - ); + // Track whether a restart is required to apply the changes in the Settings json file + // This does not care for inheritance + // It checks whether a proposed change from this UI to a settings.json file requires a restart to take effect in the app + const pendingRestartRequiredSettings = useMemo(() => { + const changed = new Set(); + const scopes: Array<[string, Settings]> = [ + ['User', settings.user.settings], + ['Workspace', settings.workspace.settings], + ['System', settings.system.settings], + ]; - // Track which settings have been modified by the user - const [modifiedSettings, setModifiedSettings] = useState>( - new Set(), - ); - - // Preserve pending changes across scope switches - type PendingValue = boolean | number | string; - const [globalPendingChanges, setGlobalPendingChanges] = useState< - Map - >(new Map()); - - // Track restart-required settings across scope changes - const [_restartRequiredSettings, setRestartRequiredSettings] = useState< - Set - >(new Set()); - - useEffect(() => { - // Base settings for selected scope - let updated = structuredClone(settings.forScope(selectedScope).settings); - // Overlay globally pending (unsaved) changes so user sees their modifications in any scope - const newModified = new Set(); - const newRestartRequired = new Set(); - for (const [key, value] of globalPendingChanges.entries()) { - const def = getSettingDefinition(key); - if (def?.type === 'boolean' && typeof value === 'boolean') { - updated = setPendingSettingValue(key, value, updated); - } else if ( - (def?.type === 'number' && typeof value === 'number') || - (def?.type === 'string' && typeof value === 'string') - ) { - updated = setPendingSettingValueAny(key, value, updated); + // Iterate through the nested map snapshot in activeRestartRequiredSettings, diff with current settings + for (const [key, initialScopeMap] of activeRestartRequiredSettings) { + for (const [scopeName, scopeSettings] of scopes) { + const currentValue = isInSettingsScope(key, scopeSettings) + ? getEffectiveValue(key, scopeSettings) + : undefined; + const initialJson = initialScopeMap.get(scopeName); + if (JSON.stringify(currentValue) !== initialJson) { + changed.add(key); + break; // one scope changed is enough + } } - newModified.add(key); - if (requiresRestart(key)) newRestartRequired.add(key); } - setPendingSettings(updated); - setModifiedSettings(newModified); - setRestartRequiredSettings(newRestartRequired); - setShowRestartPrompt(newRestartRequired.size > 0); - }, [selectedScope, settings, globalPendingChanges]); + return changed; + }, [settings, activeRestartRequiredSettings]); + + const showRestartPrompt = pendingRestartRequiredSettings.size > 0; // Calculate max width for the left column (Label/Description) to keep values aligned or close const maxLabelOrDescriptionWidth = useMemo(() => { @@ -222,16 +224,10 @@ export function SettingsDialog({ return settingKeys.map((key) => { const definition = getSettingDefinition(key); - const type = definition?.type ?? 'string'; + const type: SettingsType = definition?.type ?? 'string'; // Get the display value (with * indicator if modified) - const displayValue = getDisplayValue( - key, - scopeSettings, - mergedSettings, - modifiedSettings, - pendingSettings, - ); + const displayValue = getDisplayValue(key, scopeSettings, mergedSettings); // Get the scope message (e.g., "(Modified in Workspace)") const scopeMessage = getScopeMessageForSetting( @@ -240,28 +236,28 @@ export function SettingsDialog({ settings, ); - // Check if the value is at default (grey it out) - const isGreyedOut = isDefaultValue(key, scopeSettings); + // Grey out values that defer to defaults + const isGreyedOut = !isInSettingsScope(key, scopeSettings); - // Get raw value for edit mode initialization - const rawValue = getEffectiveValue(key, pendingSettings, {}); + // Some settings can be edited by an inline editor + const rawValue = getEffectiveValue(key, scopeSettings); + // The inline editor needs a string but non primitive settings like Arrays and Objects exist + const editValue = getEditValue(type, rawValue); return { key, label: definition?.label || key, description: definition?.description, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - type: type as 'boolean' | 'number' | 'string' | 'enum', + type, displayValue, isGreyedOut, scopeMessage, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - rawValue: rawValue as string | number | boolean | undefined, + rawValue, + editValue, }; }); - }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); + }, [settingKeys, selectedScope, settings]); - // Scope selection handler const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); }, []); @@ -273,17 +269,21 @@ export function SettingsDialog({ if (!TOGGLE_TYPES.has(definition?.type)) { return; } - const currentValue = getEffectiveValue(key, pendingSettings, {}); + + const scopeSettings = settings.forScope(selectedScope).settings; + const currentValue = getEffectiveValue(key, scopeSettings); let newValue: SettingsValue; + if (definition?.type === 'boolean') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - newValue = !(currentValue as boolean); - setPendingSettings((prev) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - setPendingSettingValue(key, newValue as boolean, prev), - ); + if (typeof currentValue !== 'boolean') { + return; + } + newValue = !currentValue; } else if (definition?.type === 'enum' && definition.options) { const options = definition.options; + if (options.length === 0) { + return; + } const currentIndex = options?.findIndex( (opt) => opt.value === currentValue, ); @@ -292,303 +292,58 @@ export function SettingsDialog({ } else { newValue = options[0].value; // loop back to start. } - setPendingSettings((prev) => - setPendingSettingValueAny(key, newValue, prev), - ); - } - - if (!requiresRestart(key)) { - const immediateSettings = new Set([key]); - const currentScopeSettings = settings.forScope(selectedScope).settings; - const immediateSettingsObject = setPendingSettingValueAny( - key, - newValue, - currentScopeSettings, - ); - debugLogger.log( - `[DEBUG SettingsDialog] Saving ${key} immediately with value:`, - newValue, - ); - saveModifiedSettings( - immediateSettings, - immediateSettingsObject, - settings, - selectedScope, - ); - - // Special handling for vim mode to sync with VimModeContext - if (key === 'general.vimMode' && newValue !== vimEnabled) { - // Call toggleVimEnabled to sync the VimModeContext local state - toggleVimEnabled().catch((error) => { - coreEvents.emitFeedback( - 'error', - 'Failed to toggle vim mode:', - error, - ); - }); - } - - // Remove from modifiedSettings since it's now saved - setModifiedSettings((prev) => { - const updated = new Set(prev); - updated.delete(key); - return updated; - }); - - // Also remove from restart-required settings if it was there - setRestartRequiredSettings((prev) => { - const updated = new Set(prev); - updated.delete(key); - return updated; - }); - - // Remove from global pending changes if present - setGlobalPendingChanges((prev) => { - if (!prev.has(key)) return prev; - const next = new Map(prev); - next.delete(key); - return next; - }); } else { - // For restart-required settings, track as modified - setModifiedSettings((prev) => { - const updated = new Set(prev).add(key); - const needsRestart = hasRestartRequiredSettings(updated); - debugLogger.log( - `[DEBUG SettingsDialog] Modified settings:`, - Array.from(updated), - 'Needs restart:', - needsRestart, - ); - if (needsRestart) { - setShowRestartPrompt(true); - setRestartRequiredSettings((prevRestart) => - new Set(prevRestart).add(key), - ); - } - return updated; - }); - - // Record pending change globally - setGlobalPendingChanges((prev) => { - const next = new Map(prev); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - next.set(key, newValue as PendingValue); - return next; - }); - } - }, - [pendingSettings, settings, selectedScope, vimEnabled, toggleVimEnabled], - ); - - // Edit commit handler - const handleEditCommit = useCallback( - (key: string, newValue: string, _item: SettingsDialogItem) => { - const definition = getSettingDefinition(key); - const type = definition?.type; - - if (newValue.trim() === '' && type === 'number') { - // Nothing entered for a number; cancel edit return; } - let parsed: string | number; - if (type === 'number') { - const numParsed = Number(newValue.trim()); - if (Number.isNaN(numParsed)) { - // Invalid number; cancel edit - return; - } - parsed = numParsed; - } else { - // For strings, use the buffer as is. - parsed = newValue; - } - - // Update pending - setPendingSettings((prev) => - setPendingSettingValueAny(key, parsed, prev), + debugLogger.log( + `[DEBUG SettingsDialog] Saving ${key} immediately with value:`, + newValue, ); - - if (!requiresRestart(key)) { - const immediateSettings = new Set([key]); - const currentScopeSettings = settings.forScope(selectedScope).settings; - const immediateSettingsObject = setPendingSettingValueAny( - key, - parsed, - currentScopeSettings, - ); - saveModifiedSettings( - immediateSettings, - immediateSettingsObject, - settings, - selectedScope, - ); - - // Remove from modified sets if present - setModifiedSettings((prev) => { - const updated = new Set(prev); - updated.delete(key); - return updated; - }); - setRestartRequiredSettings((prev) => { - const updated = new Set(prev); - updated.delete(key); - return updated; - }); - - // Remove from global pending since it's immediately saved - setGlobalPendingChanges((prev) => { - if (!prev.has(key)) return prev; - const next = new Map(prev); - next.delete(key); - return next; - }); - } else { - // Mark as modified and needing restart - setModifiedSettings((prev) => { - const updated = new Set(prev).add(key); - const needsRestart = hasRestartRequiredSettings(updated); - if (needsRestart) { - setShowRestartPrompt(true); - setRestartRequiredSettings((prevRestart) => - new Set(prevRestart).add(key), - ); - } - return updated; - }); - - // Record pending change globally for persistence across scopes - setGlobalPendingChanges((prev) => { - const next = new Map(prev); - next.set(key, parsed as PendingValue); - return next; - }); - } + setSetting(selectedScope, key, newValue); }, - [settings, selectedScope], + [settings, selectedScope, setSetting], + ); + + // For inline editor + const handleEditCommit = useCallback( + (key: string, newValue: string, _item: SettingsDialogItem) => { + const definition = getSettingDefinition(key); + const type: SettingsType = definition?.type ?? 'string'; + const parsed = parseEditedValue(type, newValue); + + if (parsed === null) { + return; + } + + setSetting(selectedScope, key, parsed); + }, + [selectedScope, setSetting], ); // Clear/reset handler - removes the value from settings.json so it falls back to default const handleItemClear = useCallback( (key: string, _item: SettingsDialogItem) => { - const defaultValue = getEffectiveDefaultValue(key, config); - - // Update local pending state to show the default value - if (typeof defaultValue === 'boolean') { - setPendingSettings((prev) => - setPendingSettingValue(key, defaultValue, prev), - ); - } else if ( - typeof defaultValue === 'number' || - typeof defaultValue === 'string' - ) { - setPendingSettings((prev) => - setPendingSettingValueAny(key, defaultValue, prev), - ); - } - - // Clear the value from settings.json (set to undefined to remove the key) - if (!requiresRestart(key)) { - settings.setValue(selectedScope, key, undefined); - - // Special handling for vim mode - if (key === 'general.vimMode') { - const booleanDefaultValue = - typeof defaultValue === 'boolean' ? defaultValue : false; - if (booleanDefaultValue !== vimEnabled) { - toggleVimEnabled().catch((error) => { - coreEvents.emitFeedback( - 'error', - 'Failed to toggle vim mode:', - error, - ); - }); - } - } - } - - // Remove from modified sets - setModifiedSettings((prev) => { - const updated = new Set(prev); - updated.delete(key); - return updated; - }); - setRestartRequiredSettings((prev) => { - const updated = new Set(prev); - updated.delete(key); - return updated; - }); - setGlobalPendingChanges((prev) => { - if (!prev.has(key)) return prev; - const next = new Map(prev); - next.delete(key); - return next; - }); - - // Update restart prompt - setShowRestartPrompt((_prev) => { - const remaining = getRestartRequiredFromModified(modifiedSettings); - return remaining.filter((k) => k !== key).length > 0; - }); + setSetting(selectedScope, key, undefined); }, - [ - config, - settings, - selectedScope, - vimEnabled, - toggleVimEnabled, - modifiedSettings, - ], + [selectedScope, setSetting], ); - const saveRestartRequiredSettings = useCallback(() => { - const restartRequiredSettings = - getRestartRequiredFromModified(modifiedSettings); - const restartRequiredSet = new Set(restartRequiredSettings); - - if (restartRequiredSet.size > 0) { - saveModifiedSettings( - restartRequiredSet, - pendingSettings, - settings, - selectedScope, - ); - - // Remove saved keys from global pending changes - setGlobalPendingChanges((prev) => { - if (prev.size === 0) return prev; - const next = new Map(prev); - for (const key of restartRequiredSet) { - next.delete(key); - } - return next; - }); - } - }, [modifiedSettings, pendingSettings, settings, selectedScope]); - - // Close handler const handleClose = useCallback(() => { - // Save any restart-required settings before closing - saveRestartRequiredSettings(); onSelect(undefined, selectedScope as SettingScope); - }, [saveRestartRequiredSettings, onSelect, selectedScope]); + }, [onSelect, selectedScope]); // Custom key handler for restart key const handleKeyPress = useCallback( (key: Key, _currentItem: SettingsDialogItem | undefined): boolean => { // 'r' key for restart if (showRestartPrompt && key.sequence === 'r') { - saveRestartRequiredSettings(); - setShowRestartPrompt(false); - setModifiedSettings(new Set()); - setRestartRequiredSettings(new Set()); if (onRestartRequest) onRestartRequest(); return true; } return false; }, - [showRestartPrompt, onRestartRequest, saveRestartRequiredSettings], + [showRestartPrompt, onRestartRequest], ); // Calculate effective max items and scope visibility based on terminal height @@ -673,11 +428,10 @@ export function SettingsDialog({ showRestartPrompt, ]); - // Footer content for restart prompt const footerContent = showRestartPrompt ? ( - To see changes, Gemini CLI must be restarted. Press r to exit and apply - changes now. + Changes that require a restart have been modified. Press r to exit and + apply changes now. ) : null; diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx index fbbc6ff517..4047ec9ef8 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx @@ -531,6 +531,37 @@ describe('BaseSettingsDialog', () => { }); describe('edit mode', () => { + it('should prioritize editValue over rawValue stringification', async () => { + const objectItem: SettingsDialogItem = { + key: 'object-setting', + label: 'Object Setting', + description: 'A complex object setting', + displayValue: '{"foo":"bar"}', + type: 'object', + rawValue: { foo: 'bar' }, + editValue: '{"foo":"bar"}', + }; + const { stdin } = await renderDialog({ + items: [objectItem], + }); + + // Enter edit mode and immediately commit + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + await act(async () => { + stdin.write(TerminalKeys.ENTER); + }); + + await waitFor(() => { + expect(mockOnEditCommit).toHaveBeenCalledWith( + 'object-setting', + '{"foo":"bar"}', + expect.objectContaining({ type: 'object' }), + ); + }); + }); + it('should commit edit on Enter', async () => { const items = createMockItems(4); const stringItem = items.find((i) => i.type === 'string')!; diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index 29592b479b..58f15aa85a 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -9,6 +9,10 @@ import { Box, Text } from 'ink'; import chalk from 'chalk'; import { theme } from '../../semantic-colors.js'; import type { LoadableSettingScope } from '../../../config/settings.js'; +import type { + SettingsType, + SettingsValue, +} from '../../../config/settingsSchema.js'; import { getScopeItems } from '../../../utils/dialogScopeUtils.js'; import { RadioButtonSelect } from './RadioButtonSelect.js'; import { TextInput } from './TextInput.js'; @@ -33,7 +37,7 @@ export interface SettingsDialogItem { /** Optional description below label */ description?: string; /** Item type for determining interaction behavior */ - type: 'boolean' | 'number' | 'string' | 'enum'; + type: SettingsType; /** Pre-formatted display value (with * if modified) */ displayValue: string; /** Grey out value (at default) */ @@ -41,7 +45,9 @@ export interface SettingsDialogItem { /** Scope message e.g., "(Modified in Workspace)" */ scopeMessage?: string; /** Raw value for edit mode initialization */ - rawValue?: string | number | boolean; + rawValue?: SettingsValue; + /** Optional pre-formatted edit buffer value for complex types */ + editValue?: string; } /** @@ -381,9 +387,11 @@ export function BaseSettingsDialog({ if (currentItem.type === 'boolean' || currentItem.type === 'enum') { onItemToggle(currentItem.key, currentItem); } else { - // Start editing for string/number + // Start editing for string/number/array/object const rawVal = currentItem.rawValue; - const initialValue = rawVal !== undefined ? String(rawVal) : ''; + const initialValue = + currentItem.editValue ?? + (rawVal !== undefined ? String(rawVal) : ''); startEditing(currentItem.key, initialValue); } return true; diff --git a/packages/cli/src/ui/contexts/SettingsContext.tsx b/packages/cli/src/ui/contexts/SettingsContext.tsx index 2c5ae37dfd..259f4c21a2 100644 --- a/packages/cli/src/ui/contexts/SettingsContext.tsx +++ b/packages/cli/src/ui/contexts/SettingsContext.tsx @@ -12,6 +12,7 @@ import type { SettingsFile, } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; +import { checkExhaustive } from '@google/gemini-cli-core'; export const SettingsContext = React.createContext( undefined, @@ -66,7 +67,7 @@ export const useSettingsStore = (): SettingsStoreValue => { case SettingScope.SystemDefaults: return snapshot.systemDefaults; default: - throw new Error(`Invalid scope: ${scope}`); + checkExhaustive(scope); } }, }), diff --git a/packages/cli/src/ui/contexts/VimModeContext.tsx b/packages/cli/src/ui/contexts/VimModeContext.tsx index d4495846d2..7f7a7ea2a3 100644 --- a/packages/cli/src/ui/contexts/VimModeContext.tsx +++ b/packages/cli/src/ui/contexts/VimModeContext.tsx @@ -4,15 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; -import type { LoadedSettings } from '../../config/settings.js'; +import { createContext, useCallback, useContext, useState } from 'react'; import { SettingScope } from '../../config/settings.js'; +import { useSettingsStore } from './SettingsContext.js'; export type VimMode = 'NORMAL' | 'INSERT'; @@ -27,35 +21,22 @@ const VimModeContext = createContext(undefined); export const VimModeProvider = ({ children, - settings, }: { children: React.ReactNode; - settings: LoadedSettings; }) => { - const initialVimEnabled = settings.merged.general.vimMode; - const [vimEnabled, setVimEnabled] = useState(initialVimEnabled); + const { settings, setSetting } = useSettingsStore(); + const vimEnabled = settings.merged.general.vimMode; const [vimMode, setVimMode] = useState('INSERT'); - useEffect(() => { - // Initialize vimEnabled from settings on mount - const enabled = settings.merged.general.vimMode; - setVimEnabled(enabled); - // When vim mode is enabled, start in INSERT mode - if (enabled) { - setVimMode('INSERT'); - } - }, [settings.merged.general.vimMode]); - const toggleVimEnabled = useCallback(async () => { const newValue = !vimEnabled; - setVimEnabled(newValue); // When enabling vim mode, start in INSERT mode if (newValue) { setVimMode('INSERT'); } - settings.setValue(SettingScope.User, 'general.vimMode', newValue); + setSetting(SettingScope.User, 'general.vimMode', newValue); return newValue; - }, [vimEnabled, settings]); + }, [vimEnabled, setSetting]); const value = { vimEnabled, diff --git a/packages/cli/src/utils/dialogScopeUtils.test.ts b/packages/cli/src/utils/dialogScopeUtils.test.ts index a2032bda6d..ab4a69886e 100644 --- a/packages/cli/src/utils/dialogScopeUtils.test.ts +++ b/packages/cli/src/utils/dialogScopeUtils.test.ts @@ -11,7 +11,7 @@ import { getScopeItems, getScopeMessageForSetting, } from './dialogScopeUtils.js'; -import { settingExistsInScope } from './settingsUtils.js'; +import { isInSettingsScope } from './settingsUtils.js'; vi.mock('../config/settings', () => ({ SettingScope: { @@ -24,7 +24,7 @@ vi.mock('../config/settings', () => ({ })); vi.mock('./settingsUtils', () => ({ - settingExistsInScope: vi.fn(), + isInSettingsScope: vi.fn(), })); describe('dialogScopeUtils', () => { @@ -53,7 +53,7 @@ describe('dialogScopeUtils', () => { }); it('should return empty string if not modified in other scopes', () => { - vi.mocked(settingExistsInScope).mockReturnValue(false); + vi.mocked(isInSettingsScope).mockReturnValue(false); const message = getScopeMessageForSetting( 'key', SettingScope.User, @@ -63,7 +63,7 @@ describe('dialogScopeUtils', () => { }); it('should return message indicating modification in other scopes', () => { - vi.mocked(settingExistsInScope).mockReturnValue(true); + vi.mocked(isInSettingsScope).mockReturnValue(true); const message = getScopeMessageForSetting( 'key', @@ -88,7 +88,7 @@ describe('dialogScopeUtils', () => { return { settings: {} }; }); - vi.mocked(settingExistsInScope).mockImplementation( + vi.mocked(isInSettingsScope).mockImplementation( (_key, settings: unknown) => { if (settings === workspaceSettings) return true; if (settings === systemSettings) return false; diff --git a/packages/cli/src/utils/dialogScopeUtils.ts b/packages/cli/src/utils/dialogScopeUtils.ts index ccf93b6a68..35c1d41917 100644 --- a/packages/cli/src/utils/dialogScopeUtils.ts +++ b/packages/cli/src/utils/dialogScopeUtils.ts @@ -4,12 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - LoadableSettingScope, - LoadedSettings, -} from '../config/settings.js'; +import type { LoadableSettingScope, Settings } from '../config/settings.js'; import { isLoadableSettingScope, SettingScope } from '../config/settings.js'; -import { settingExistsInScope } from './settingsUtils.js'; +import { isInSettingsScope } from './settingsUtils.js'; /** * Shared scope labels for dialog components that need to display setting scopes @@ -43,7 +40,9 @@ export function getScopeItems(): Array<{ export function getScopeMessageForSetting( settingKey: string, selectedScope: LoadableSettingScope, - settings: LoadedSettings, + settings: { + forScope: (scope: LoadableSettingScope) => { settings: Settings }; + }, ): string { const otherScopes = Object.values(SettingScope) .filter(isLoadableSettingScope) @@ -51,7 +50,7 @@ export function getScopeMessageForSetting( const modifiedInOtherScopes = otherScopes.filter((scope) => { const scopeSettings = settings.forScope(scope).settings; - return settingExistsInScope(settingKey, scopeSettings); + return isInSettingsScope(settingKey, scopeSettings); }); if (modifiedInOtherScopes.length === 0) { @@ -60,7 +59,7 @@ export function getScopeMessageForSetting( const modifiedScopesStr = modifiedInOtherScopes.join(', '); const currentScopeSettings = settings.forScope(selectedScope).settings; - const existsInCurrentScope = settingExistsInScope( + const existsInCurrentScope = isInSettingsScope( settingKey, currentScopeSettings, ); diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts index 75bdeb65e6..d06743a4e9 100644 --- a/packages/cli/src/utils/settingsUtils.test.ts +++ b/packages/cli/src/utils/settingsUtils.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { // Schema utilities getSettingsByCategory, @@ -22,18 +22,10 @@ import { getDialogSettingsByCategory, getDialogSettingsByType, getDialogSettingKeys, - // Business logic utilities - getSettingValue, - isSettingModified, + // Business logic utilities, TEST_ONLY, - settingExistsInScope, - setPendingSettingValue, - hasRestartRequiredSettings, - getRestartRequiredFromModified, + isInSettingsScope, getDisplayValue, - isDefaultValue, - isValueInherited, - getEffectiveDisplayValue, } from './settingsUtils.js'; import { getSettingsSchema, @@ -255,41 +247,15 @@ describe('SettingsUtils', () => { describe('getEffectiveValue', () => { it('should return value from settings when set', () => { const settings = makeMockSettings({ ui: { requiresRestart: true } }); - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: false }, - }); - const value = getEffectiveValue( - 'ui.requiresRestart', - settings, - mergedSettings, - ); - expect(value).toBe(true); - }); - - it('should return value from merged settings when not set in current scope', () => { - const settings = makeMockSettings({}); - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: true }, - }); - - const value = getEffectiveValue( - 'ui.requiresRestart', - settings, - mergedSettings, - ); + const value = getEffectiveValue('ui.requiresRestart', settings); expect(value).toBe(true); }); it('should return default value when not set anywhere', () => { const settings = makeMockSettings({}); - const mergedSettings = makeMockSettings({}); - const value = getEffectiveValue( - 'ui.requiresRestart', - settings, - mergedSettings, - ); + const value = getEffectiveValue('ui.requiresRestart', settings); expect(value).toBe(false); // default value }); @@ -297,27 +263,18 @@ describe('SettingsUtils', () => { const settings = makeMockSettings({ ui: { accessibility: { enableLoadingPhrases: false } }, }); - const mergedSettings = makeMockSettings({ - ui: { accessibility: { enableLoadingPhrases: true } }, - }); const value = getEffectiveValue( 'ui.accessibility.enableLoadingPhrases', settings, - mergedSettings, ); expect(value).toBe(false); }); it('should return undefined for invalid settings', () => { const settings = makeMockSettings({}); - const mergedSettings = makeMockSettings({}); - const value = getEffectiveValue( - 'invalidSetting', - settings, - mergedSettings, - ); + const value = getEffectiveValue('invalidSetting', settings); expect(value).toBeUndefined(); }); }); @@ -483,7 +440,9 @@ describe('SettingsUtils', () => { expect(dialogKeys.length).toBeGreaterThan(0); }); - it('should handle nested settings display correctly', () => { + const nestedDialogKey = 'context.fileFiltering.respectGitIgnore'; + + function mockNestedDialogSchema() { vi.mocked(getSettingsSchema).mockReturnValue({ context: { type: 'object', @@ -517,128 +476,27 @@ describe('SettingsUtils', () => { }, }, } as unknown as SettingsSchemaType); + } - // Test the specific issue with fileFiltering.respectGitIgnore - const key = 'context.fileFiltering.respectGitIgnore'; - const initialSettings = makeMockSettings({}); - const pendingSettings = makeMockSettings({}); + it('should include nested file filtering setting in dialog keys', () => { + mockNestedDialogSchema(); - // Set the nested setting to true - const updatedPendingSettings = setPendingSettingValue( - key, - true, - pendingSettings, - ); - - // Check if the setting exists in pending settings - const existsInPending = settingExistsInScope( - key, - updatedPendingSettings, - ); - expect(existsInPending).toBe(true); - - // Get the value from pending settings - const valueFromPending = getSettingValue( - key, - updatedPendingSettings, - {}, - ); - expect(valueFromPending).toBe(true); - - // Test getDisplayValue should show the pending change - const displayValue = getDisplayValue( - key, - initialSettings, - {}, - new Set(), - updatedPendingSettings, - ); - expect(displayValue).toBe('true'); // Should show true (no * since value matches default) - - // Test that modified settings also show the * indicator - const modifiedSettings = new Set([key]); - const displayValueWithModified = getDisplayValue( - key, - initialSettings, - {}, - modifiedSettings, - {}, - ); - expect(displayValueWithModified).toBe('true*'); // Should show true* because it's in modified settings and default is true + const dialogKeys = getDialogSettingKeys(); + expect(dialogKeys).toContain(nestedDialogKey); }); }); }); describe('Business Logic Utilities', () => { - describe('getSettingValue', () => { - it('should return value from settings when set', () => { - const settings = makeMockSettings({ ui: { requiresRestart: true } }); - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: false }, - }); - - const value = getSettingValue( - 'ui.requiresRestart', - settings, - mergedSettings, - ); - expect(value).toBe(true); - }); - - it('should return value from merged settings when not set in current scope', () => { - const settings = makeMockSettings({}); - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: true }, - }); - - const value = getSettingValue( - 'ui.requiresRestart', - settings, - mergedSettings, - ); - expect(value).toBe(true); - }); - - it('should return default value for invalid setting', () => { - const settings = makeMockSettings({}); - const mergedSettings = makeMockSettings({}); - - const value = getSettingValue( - 'invalidSetting', - settings, - mergedSettings, - ); - expect(value).toBe(false); // Default fallback - }); - }); - - describe('isSettingModified', () => { - it('should return true when value differs from default', () => { - expect(isSettingModified('ui.requiresRestart', true)).toBe(true); - expect( - isSettingModified('ui.accessibility.enableLoadingPhrases', false), - ).toBe(true); - }); - - it('should return false when value matches default', () => { - expect(isSettingModified('ui.requiresRestart', false)).toBe(false); - expect( - isSettingModified('ui.accessibility.enableLoadingPhrases', true), - ).toBe(false); - }); - }); - - describe('settingExistsInScope', () => { + describe('isInSettingsScope', () => { it('should return true for top-level settings that exist', () => { const settings = makeMockSettings({ ui: { requiresRestart: true } }); - expect(settingExistsInScope('ui.requiresRestart', settings)).toBe(true); + expect(isInSettingsScope('ui.requiresRestart', settings)).toBe(true); }); it('should return false for top-level settings that do not exist', () => { const settings = makeMockSettings({}); - expect(settingExistsInScope('ui.requiresRestart', settings)).toBe( - false, - ); + expect(isInSettingsScope('ui.requiresRestart', settings)).toBe(false); }); it('should return true for nested settings that exist', () => { @@ -646,121 +504,25 @@ describe('SettingsUtils', () => { ui: { accessibility: { enableLoadingPhrases: true } }, }); expect( - settingExistsInScope( - 'ui.accessibility.enableLoadingPhrases', - settings, - ), + isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings), ).toBe(true); }); it('should return false for nested settings that do not exist', () => { const settings = makeMockSettings({}); expect( - settingExistsInScope( - 'ui.accessibility.enableLoadingPhrases', - settings, - ), + isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings), ).toBe(false); }); it('should return false when parent exists but child does not', () => { const settings = makeMockSettings({ ui: { accessibility: {} } }); expect( - settingExistsInScope( - 'ui.accessibility.enableLoadingPhrases', - settings, - ), + isInSettingsScope('ui.accessibility.enableLoadingPhrases', settings), ).toBe(false); }); }); - describe('setPendingSettingValue', () => { - it('should set top-level setting value', () => { - const pendingSettings = makeMockSettings({}); - const result = setPendingSettingValue( - 'ui.hideWindowTitle', - true, - pendingSettings, - ); - - expect(result.ui?.hideWindowTitle).toBe(true); - }); - - it('should set nested setting value', () => { - const pendingSettings = makeMockSettings({}); - const result = setPendingSettingValue( - 'ui.accessibility.enableLoadingPhrases', - true, - pendingSettings, - ); - - expect(result.ui?.accessibility?.enableLoadingPhrases).toBe(true); - }); - - it('should preserve existing nested settings', () => { - const pendingSettings = makeMockSettings({ - ui: { accessibility: { enableLoadingPhrases: false } }, - }); - const result = setPendingSettingValue( - 'ui.accessibility.enableLoadingPhrases', - true, - pendingSettings, - ); - - expect(result.ui?.accessibility?.enableLoadingPhrases).toBe(true); - }); - - it('should not mutate original settings', () => { - const pendingSettings = makeMockSettings({}); - setPendingSettingValue('ui.requiresRestart', true, pendingSettings); - - expect(pendingSettings).toEqual({}); - }); - }); - - describe('hasRestartRequiredSettings', () => { - it('should return true when modified settings require restart', () => { - const modifiedSettings = new Set([ - 'advanced.autoConfigureMemory', - 'ui.requiresRestart', - ]); - expect(hasRestartRequiredSettings(modifiedSettings)).toBe(true); - }); - - it('should return false when no modified settings require restart', () => { - const modifiedSettings = new Set(['test']); - expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false); - }); - - it('should return false for empty set', () => { - const modifiedSettings = new Set(); - expect(hasRestartRequiredSettings(modifiedSettings)).toBe(false); - }); - }); - - describe('getRestartRequiredFromModified', () => { - it('should return only settings that require restart', () => { - const modifiedSettings = new Set([ - 'ui.requiresRestart', - 'test', - ]); - const result = getRestartRequiredFromModified(modifiedSettings); - - expect(result).toContain('ui.requiresRestart'); - expect(result).not.toContain('test'); - }); - - it('should return empty array when no settings require restart', () => { - const modifiedSettings = new Set([ - 'requiresRestart', - 'hideTips', - ]); - const result = getRestartRequiredFromModified(modifiedSettings); - - expect(result).toEqual([]); - }); - }); - describe('getDisplayValue', () => { describe('enum behavior', () => { enum StringEnum { @@ -830,14 +592,8 @@ describe('SettingsUtils', () => { const mergedSettings = makeMockSettings({ ui: { theme: NumberEnum.THREE }, }); - const modifiedSettings = new Set(); - const result = getDisplayValue( - 'ui.theme', - settings, - mergedSettings, - modifiedSettings, - ); + const result = getDisplayValue('ui.theme', settings, mergedSettings); expect(result).toBe('Three*'); }); @@ -867,13 +623,11 @@ describe('SettingsUtils', () => { }, }, } as unknown as SettingsSchemaType); - const modifiedSettings = new Set(); const result = getDisplayValue( 'ui.theme', makeMockSettings({}), makeMockSettings({}), - modifiedSettings, ); expect(result).toBe('Three'); }); @@ -886,14 +640,8 @@ describe('SettingsUtils', () => { const mergedSettings = makeMockSettings({ ui: { theme: StringEnum.BAR }, }); - const modifiedSettings = new Set(); - const result = getDisplayValue( - 'ui.theme', - settings, - mergedSettings, - modifiedSettings, - ); + const result = getDisplayValue('ui.theme', settings, mergedSettings); expect(result).toBe('Bar*'); }); @@ -907,14 +655,8 @@ describe('SettingsUtils', () => { } as unknown as SettingsSchemaType); const settings = makeMockSettings({ ui: { theme: 'xyz' } }); const mergedSettings = makeMockSettings({ ui: { theme: 'xyz' } }); - const modifiedSettings = new Set(); - const result = getDisplayValue( - 'ui.theme', - settings, - mergedSettings, - modifiedSettings, - ); + const result = getDisplayValue('ui.theme', settings, mergedSettings); expect(result).toBe('xyz*'); }); @@ -926,242 +668,71 @@ describe('SettingsUtils', () => { }, }, } as unknown as SettingsSchemaType); - const modifiedSettings = new Set(); const result = getDisplayValue( 'ui.theme', makeMockSettings({}), makeMockSettings({}), - modifiedSettings, ); expect(result).toBe('Bar'); }); }); - it('should show value without * when setting matches default', () => { - const settings = makeMockSettings({ - ui: { requiresRestart: false }, - }); // false matches default, so no * + it('should show value with * when setting exists in scope', () => { + const settings = makeMockSettings({ ui: { requiresRestart: true } }); const mergedSettings = makeMockSettings({ - ui: { requiresRestart: false }, + ui: { requiresRestart: true }, }); - const modifiedSettings = new Set(); const result = getDisplayValue( 'ui.requiresRestart', settings, mergedSettings, - modifiedSettings, ); - expect(result).toBe('false*'); + expect(result).toBe('true*'); }); - - it('should show default value when setting is not in scope', () => { + it('should not show * when key is not in scope', () => { const settings = makeMockSettings({}); // no setting in scope const mergedSettings = makeMockSettings({ ui: { requiresRestart: false }, }); - const modifiedSettings = new Set(); const result = getDisplayValue( 'ui.requiresRestart', settings, mergedSettings, - modifiedSettings, ); expect(result).toBe('false'); // shows default value }); - it('should show value with * when changed from default', () => { - const settings = makeMockSettings({ ui: { requiresRestart: true } }); // true is different from default (false) - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: true }, - }); - const modifiedSettings = new Set(); - - const result = getDisplayValue( - 'ui.requiresRestart', - settings, - mergedSettings, - modifiedSettings, - ); - expect(result).toBe('true*'); - }); - - it('should show default value without * when setting does not exist in scope', () => { - const settings = makeMockSettings({}); // setting doesn't exist in scope, show default - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: false }, - }); - const modifiedSettings = new Set(); - - const result = getDisplayValue( - 'ui.requiresRestart', - settings, - mergedSettings, - modifiedSettings, - ); - expect(result).toBe('false'); // default value (false) without * - }); - - it('should show value with * when user changes from default', () => { - const settings = makeMockSettings({}); // setting doesn't exist in scope originally - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: false }, - }); - const modifiedSettings = new Set(['ui.requiresRestart']); - const pendingSettings = makeMockSettings({ - ui: { requiresRestart: true }, - }); // user changed to true - - const result = getDisplayValue( - 'ui.requiresRestart', - settings, - mergedSettings, - modifiedSettings, - pendingSettings, - ); - expect(result).toBe('true*'); // changed from default (false) to true - }); - }); - - describe('isDefaultValue', () => { - it('should return true when setting does not exist in scope', () => { - const settings = makeMockSettings({}); // setting doesn't exist - - const result = isDefaultValue('ui.requiresRestart', settings); - expect(result).toBe(true); - }); - - it('should return false when setting exists in scope', () => { - const settings = makeMockSettings({ ui: { requiresRestart: true } }); // setting exists - - const result = isDefaultValue('ui.requiresRestart', settings); - expect(result).toBe(false); - }); - - it('should return true when nested setting does not exist in scope', () => { - const settings = makeMockSettings({}); // nested setting doesn't exist - - const result = isDefaultValue( - 'ui.accessibility.enableLoadingPhrases', - settings, - ); - expect(result).toBe(true); - }); - - it('should return false when nested setting exists in scope', () => { + it('should show value with * when setting exists in scope, even when it matches default', () => { const settings = makeMockSettings({ - ui: { accessibility: { enableLoadingPhrases: true } }, - }); // nested setting exists - - const result = isDefaultValue( - 'ui.accessibility.enableLoadingPhrases', - settings, - ); - expect(result).toBe(false); - }); - }); - - describe('isValueInherited', () => { - it('should return false for top-level settings that exist in scope', () => { - const settings = makeMockSettings({ ui: { requiresRestart: true } }); - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: true }, - }); - - const result = isValueInherited( - 'ui.requiresRestart', - settings, - mergedSettings, - ); - expect(result).toBe(false); - }); - - it('should return true for top-level settings that do not exist in scope', () => { - const settings = makeMockSettings({}); - const mergedSettings = makeMockSettings({ - ui: { requiresRestart: true }, - }); - - const result = isValueInherited( - 'ui.requiresRestart', - settings, - mergedSettings, - ); - expect(result).toBe(true); - }); - - it('should return false for nested settings that exist in scope', () => { - const settings = makeMockSettings({ - ui: { accessibility: { enableLoadingPhrases: true } }, - }); - const mergedSettings = makeMockSettings({ - ui: { accessibility: { enableLoadingPhrases: true } }, - }); - - const result = isValueInherited( - 'ui.accessibility.enableLoadingPhrases', - settings, - mergedSettings, - ); - expect(result).toBe(false); - }); - - it('should return true for nested settings that do not exist in scope', () => { - const settings = makeMockSettings({}); - const mergedSettings = makeMockSettings({ - ui: { accessibility: { enableLoadingPhrases: true } }, - }); - - const result = isValueInherited( - 'ui.accessibility.enableLoadingPhrases', - settings, - mergedSettings, - ); - expect(result).toBe(true); - }); - }); - - describe('getEffectiveDisplayValue', () => { - it('should return value from settings when available', () => { - const settings = makeMockSettings({ ui: { requiresRestart: true } }); + ui: { requiresRestart: false }, + }); // false matches default, but key is explicitly set in scope const mergedSettings = makeMockSettings({ ui: { requiresRestart: false }, }); - const result = getEffectiveDisplayValue( + const result = getDisplayValue( 'ui.requiresRestart', settings, mergedSettings, ); - expect(result).toBe(true); + expect(result).toBe('false*'); }); - it('should return value from merged settings when not in scope', () => { - const settings = makeMockSettings({}); + it('should show schema default (not inherited merged value) when key is not in scope', () => { + const settings = makeMockSettings({}); // no setting in current scope const mergedSettings = makeMockSettings({ ui: { requiresRestart: true }, - }); + }); // inherited merged value differs from schema default (false) - const result = getEffectiveDisplayValue( + const result = getDisplayValue( 'ui.requiresRestart', settings, mergedSettings, ); - expect(result).toBe(true); - }); - - it('should return default value for undefined values', () => { - const settings = makeMockSettings({}); - const mergedSettings = makeMockSettings({}); - - const result = getEffectiveDisplayValue( - 'ui.requiresRestart', - settings, - mergedSettings, - ); - expect(result).toBe(false); // Default value + expect(result).toBe('false'); }); }); }); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index 3fa1d8bd5d..87ca920899 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -4,11 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - Settings, - LoadedSettings, - LoadableSettingScope, -} from '../config/settings.js'; +import type { Settings } from '../config/settings.js'; import type { SettingDefinition, SettingsSchema, @@ -52,9 +48,6 @@ function clearFlattenedSchema() { _FLATTENED_SCHEMA = undefined; } -/** - * Get all settings grouped by category - */ export function getSettingsByCategory(): Record< string, Array @@ -75,25 +68,16 @@ export function getSettingsByCategory(): Record< return categories; } -/** - * Get a setting definition by key - */ export function getSettingDefinition( key: string, ): (SettingDefinition & { key: string }) | undefined { return getFlattenedSchema()[key]; } -/** - * Check if a setting requires restart - */ export function requiresRestart(key: string): boolean { return getFlattenedSchema()[key]?.requiresRestart ?? false; } -/** - * Get the default value for a setting - */ export function getDefaultValue(key: string): SettingsValue { return getFlattenedSchema()[key]?.default; } @@ -120,9 +104,6 @@ export function getEffectiveDefaultValue( return getDefaultValue(key); } -/** - * Get all setting keys that require restart - */ export function getRestartRequiredSettings(): string[] { return Object.values(getFlattenedSchema()) .filter((definition) => definition.requiresRestart) @@ -130,35 +111,55 @@ export function getRestartRequiredSettings(): string[] { } /** - * Recursively gets a value from a nested object using a key path array. + * Get restart-required setting keys that are also visible in the dialog. + * Non-dialog restart keys (e.g. parent container objects like mcpServers, tools) + * are excluded because users cannot change them through the dialog. */ -export function getNestedValue( - obj: Record, - path: string[], -): unknown { - const [first, ...rest] = path; - if (!first || !(first in obj)) { - return undefined; - } - const value = obj[first]; - if (rest.length === 0) { - return value; - } - if (value && typeof value === 'object' && value !== null) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return getNestedValue(value as Record, rest); - } - return undefined; +export function getDialogRestartRequiredSettings(): string[] { + return Object.values(getFlattenedSchema()) + .filter( + (definition) => + definition.requiresRestart && definition.showInDialog !== false, + ) + .map((definition) => definition.key); +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isSettingsValue(value: unknown): value is SettingsValue { + if (value === undefined) return true; + if (value === null) return false; + const type = typeof value; + return ( + type === 'string' || + type === 'number' || + type === 'boolean' || + type === 'object' + ); } /** - * Get the effective value for a setting, considering inheritance from higher scopes - * Always returns a value (never undefined) - falls back to default if not set anywhere + * Gets a value from a nested object using a key path array iteratively. + */ +export function getNestedValue(obj: unknown, path: string[]): unknown { + let current = obj; + for (const key of path) { + if (!isRecord(current) || !(key in current)) { + return undefined; + } + current = current[key]; + } + return current; +} + +/** + * Get the effective value for a setting falling back to the default value */ export function getEffectiveValue( key: string, settings: Settings, - mergedSettings: Settings, ): SettingsValue { const definition = getSettingDefinition(key); if (!definition) { @@ -168,33 +169,19 @@ export function getEffectiveValue( const path = key.split('.'); // Check the current scope's settings first - let value = getNestedValue(settings as Record, path); - if (value !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return value as SettingsValue; - } - - // Check the merged settings for an inherited value - value = getNestedValue(mergedSettings as Record, path); - if (value !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return value as SettingsValue; + const value = getNestedValue(settings, path); + if (value !== undefined && isSettingsValue(value)) { + return value; } // Return default value if no value is set anywhere return definition.default; } -/** - * Get all setting keys from the schema - */ export function getAllSettingKeys(): string[] { return Object.keys(getFlattenedSchema()); } -/** - * Get settings by type - */ export function getSettingsByType( type: SettingsType, ): Array { @@ -203,9 +190,6 @@ export function getSettingsByType( ); } -/** - * Get settings that require restart - */ export function getSettingsRequiringRestart(): Array< SettingDefinition & { key: string; @@ -223,22 +207,22 @@ export function isValidSettingKey(key: string): boolean { return key in getFlattenedSchema(); } -/** - * Get the category for a setting - */ export function getSettingCategory(key: string): string | undefined { return getFlattenedSchema()[key]?.category; } -/** - * Check if a setting should be shown in the settings dialog - */ export function shouldShowInDialog(key: string): boolean { return getFlattenedSchema()[key]?.showInDialog ?? true; // Default to true for backward compatibility } +export function getDialogSettingKeys(): string[] { + return Object.values(getFlattenedSchema()) + .filter((definition) => definition.showInDialog !== false) + .map((definition) => definition.key); +} + /** - * Get all settings that should be shown in the dialog, grouped by category + * Get all settings that should be shown in the dialog, grouped by category like "Advanced", "General", etc. */ export function getDialogSettingsByCategory(): Record< string, @@ -262,9 +246,6 @@ export function getDialogSettingsByCategory(): Record< return categories; } -/** - * Get settings by type that should be shown in the dialog - */ export function getDialogSettingsByType( type: SettingsType, ): Array { @@ -274,197 +255,30 @@ export function getDialogSettingsByType( ); } -/** - * Get all setting keys that should be shown in the dialog - */ -export function getDialogSettingKeys(): string[] { - return Object.values(getFlattenedSchema()) - .filter((definition) => definition.showInDialog !== false) - .map((definition) => definition.key); -} - -// ============================================================================ -// BUSINESS LOGIC UTILITIES (Higher-level utilities for setting operations) -// ============================================================================ - -/** - * Get the current value for a setting in a specific scope - * Always returns a value (never undefined) - falls back to default if not set anywhere - */ -export function getSettingValue( - key: string, - settings: Settings, - mergedSettings: Settings, -): boolean { - const definition = getSettingDefinition(key); - if (!definition) { - return false; // Default fallback for invalid settings - } - - const value = getEffectiveValue(key, settings, mergedSettings); - // Ensure we return a boolean value, converting from the more general type - if (typeof value === 'boolean') { - return value; - } - - return false; // Final fallback -} - -/** - * Check if a setting value is modified from its default - */ -export function isSettingModified(key: string, value: boolean): boolean { - const defaultValue = getDefaultValue(key); - // Handle type comparison properly - if (typeof defaultValue === 'boolean') { - return value !== defaultValue; - } - // If default is not a boolean, consider it modified if value is true - return value === true; -} - -/** - * Check if a setting exists in the original settings file for a scope - */ -export function settingExistsInScope( +export function isInSettingsScope( key: string, scopeSettings: Settings, ): boolean { const path = key.split('.'); - const value = getNestedValue(scopeSettings as Record, path); + const value = getNestedValue(scopeSettings, path); return value !== undefined; } /** - * Recursively sets a value in a nested object using a key path array. - */ -function setNestedValue( - obj: Record, - path: string[], - value: unknown, -): Record { - const [first, ...rest] = path; - if (!first) { - return obj; - } - - if (rest.length === 0) { - obj[first] = value; - return obj; - } - - if (!obj[first] || typeof obj[first] !== 'object') { - obj[first] = {}; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - setNestedValue(obj[first] as Record, rest, value); - return obj; -} - -/** - * Set a setting value in the pending settings - */ -export function setPendingSettingValue( - key: string, - value: boolean, - pendingSettings: Settings, -): Settings { - const path = key.split('.'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const newSettings = JSON.parse(JSON.stringify(pendingSettings)); - setNestedValue(newSettings, path, value); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return newSettings; -} - -/** - * Generic setter: Set a setting value (boolean, number, string, etc.) in the pending settings - */ -export function setPendingSettingValueAny( - key: string, - value: SettingsValue, - pendingSettings: Settings, -): Settings { - const path = key.split('.'); - const newSettings = structuredClone(pendingSettings); - setNestedValue(newSettings, path, value); - return newSettings; -} - -/** - * Check if any modified settings require a restart - */ -export function hasRestartRequiredSettings( - modifiedSettings: Set, -): boolean { - return Array.from(modifiedSettings).some((key) => requiresRestart(key)); -} - -/** - * Get the restart required settings from a set of modified settings - */ -export function getRestartRequiredFromModified( - modifiedSettings: Set, -): string[] { - return Array.from(modifiedSettings).filter((key) => requiresRestart(key)); -} - -/** - * Save modified settings to the appropriate scope - */ -export function saveModifiedSettings( - modifiedSettings: Set, - pendingSettings: Settings, - loadedSettings: LoadedSettings, - scope: LoadableSettingScope, -): void { - modifiedSettings.forEach((settingKey) => { - const path = settingKey.split('.'); - const value = getNestedValue( - pendingSettings as Record, - path, - ); - - if (value === undefined) { - return; - } - - const existsInOriginalFile = settingExistsInScope( - settingKey, - loadedSettings.forScope(scope).settings, - ); - - const isDefaultValue = value === getDefaultValue(settingKey); - - if (existsInOriginalFile || !isDefaultValue) { - loadedSettings.setValue(scope, settingKey, value); - } - }); -} - -/** - * Get the display value for a setting, showing current scope value with default change indicator + * Appends a star (*) to settings that exist in the scope */ export function getDisplayValue( key: string, - settings: Settings, + scopeSettings: Settings, _mergedSettings: Settings, - modifiedSettings: Set, - pendingSettings?: Settings, ): string { - // Prioritize pending changes if user has modified this setting const definition = getSettingDefinition(key); + const existsInScope = isInSettingsScope(key, scopeSettings); let value: SettingsValue; - if (pendingSettings && settingExistsInScope(key, pendingSettings)) { - // Show the value from the pending (unsaved) edits when it exists - value = getEffectiveValue(key, pendingSettings, {}); - } else if (settingExistsInScope(key, settings)) { - // Show the value defined at the current scope if present - value = getEffectiveValue(key, settings, {}); + if (existsInScope) { + value = getEffectiveValue(key, scopeSettings); } else { - // Fall back to the schema default when the key is unset in this scope value = getDefaultValue(key); } @@ -475,50 +289,108 @@ export function getDisplayValue( valueString = option?.label ?? `${value}`; } - // Check if value is different from default OR if it's in modified settings OR if there are pending changes - const defaultValue = getDefaultValue(key); - const isChangedFromDefault = value !== defaultValue; - const isInModifiedSettings = modifiedSettings.has(key); - - // Mark as modified if setting exists in current scope OR is in modified settings - if (settingExistsInScope(key, settings) || isInModifiedSettings) { - return `${valueString}*`; // * indicates setting is set in current scope - } - if (isChangedFromDefault || isInModifiedSettings) { - return `${valueString}*`; // * indicates changed from default value + if (existsInScope) { + return `${valueString}*`; } return valueString; } -/** - * Check if a setting doesn't exist in current scope (should be greyed out) - */ -export function isDefaultValue(key: string, settings: Settings): boolean { - return !settingExistsInScope(key, settings); +/**Utilities for parsing Settings that can be inline edited by the user typing out values */ +function tryParseJsonStringArray(input: string): string[] | null { + try { + const parsed: unknown = JSON.parse(input); + if ( + Array.isArray(parsed) && + parsed.every((item): item is string => typeof item === 'string') + ) { + return parsed; + } + return null; + } catch { + return null; + } } -/** - * Check if a setting value is inherited (not set at current scope) - */ -export function isValueInherited( - key: string, - settings: Settings, - _mergedSettings: Settings, -): boolean { - return !settingExistsInScope(key, settings); +function tryParseJsonObject(input: string): Record | null { + try { + const parsed: unknown = JSON.parse(input); + if (isRecord(parsed) && !Array.isArray(parsed)) { + return parsed; + } + return null; + } catch { + return null; + } } -/** - * Get the effective value for display, considering inheritance - * Always returns a boolean value (never undefined) - */ -export function getEffectiveDisplayValue( - key: string, - settings: Settings, - mergedSettings: Settings, -): boolean { - return getSettingValue(key, settings, mergedSettings); +function parseStringArrayValue(input: string): string[] { + const trimmed = input.trim(); + if (trimmed === '') return []; + + return ( + tryParseJsonStringArray(trimmed) ?? + input + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0) + ); +} + +function parseObjectValue(input: string): Record | null { + const trimmed = input.trim(); + if (trimmed === '') { + return null; + } + + return tryParseJsonObject(trimmed); +} + +export function parseEditedValue( + type: SettingsType, + newValue: string, +): SettingsValue | null { + if (type === 'number') { + if (newValue.trim() === '') { + return null; + } + + const numParsed = Number(newValue.trim()); + if (Number.isNaN(numParsed)) { + return null; + } + + return numParsed; + } + + if (type === 'array') { + return parseStringArrayValue(newValue); + } + + if (type === 'object') { + return parseObjectValue(newValue); + } + + return newValue; +} + +export function getEditValue( + type: SettingsType, + rawValue: SettingsValue, +): string | undefined { + if (rawValue === undefined) { + return undefined; + } + + if (type === 'array' && Array.isArray(rawValue)) { + return rawValue.join(', '); + } + + if (type === 'object' && rawValue !== null && typeof rawValue === 'object') { + return JSON.stringify(rawValue); + } + + return undefined; } export const TEST_ONLY = { clearFlattenedSchema }; From dd9ccc980780109f6c2229351afaa304120ec2ca Mon Sep 17 00:00:00 2001 From: Nayana Parameswarappa <138813846+Nayana-Parameswarappa@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:37:44 -0800 Subject: [PATCH 16/21] Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider (#20121) --- .../core/src/mcp/mcp-oauth-provider.test.ts | 84 ++++++++++++++++ packages/core/src/mcp/mcp-oauth-provider.ts | 97 +++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 packages/core/src/mcp/mcp-oauth-provider.test.ts create mode 100644 packages/core/src/mcp/mcp-oauth-provider.ts diff --git a/packages/core/src/mcp/mcp-oauth-provider.test.ts b/packages/core/src/mcp/mcp-oauth-provider.test.ts new file mode 100644 index 0000000000..a7891f035b --- /dev/null +++ b/packages/core/src/mcp/mcp-oauth-provider.test.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + MCPOAuthClientProvider, + type OAuthAuthorizationResponse, +} from './mcp-oauth-provider.js'; +import type { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; + +describe('MCPOAuthClientProvider', () => { + const mockRedirectUrl = 'http://localhost:8090/callback'; + const mockClientMetadata: OAuthClientMetadata = { + client_name: 'Test Client', + redirect_uris: [mockRedirectUrl], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post', + scope: 'test-scope', + }; + const mockState = 'test-state-123'; + + describe('oauth flow', () => { + it('should support full OAuth flow', async () => { + const onRedirectMock = vi.fn(); + const provider = new MCPOAuthClientProvider( + mockRedirectUrl, + mockClientMetadata, + mockState, + onRedirectMock, + ); + + // Step 1: Save client information + const clientInfo: OAuthClientInformation = { + client_id: 'my-client-id', + client_secret: 'my-client-secret', + }; + provider.saveClientInformation(clientInfo); + + // Step 2: Save code verifier + provider.saveCodeVerifier('my-code-verifier'); + + // Step 3: Set up callback server + const mockAuthResponse: OAuthAuthorizationResponse = { + code: 'authorization-code', + state: mockState, + }; + const mockServer = { + port: Promise.resolve(8090), + waitForResponse: vi.fn().mockResolvedValue(mockAuthResponse), + close: vi.fn().mockResolvedValue(undefined), + }; + provider.saveCallbackServer(mockServer); + + // Step 4: Redirect to authorization + const authUrl = new URL('http://auth.example.com/authorize'); + await provider.redirectToAuthorization(authUrl); + + // Step 5: Save tokens after exchange + const tokens: OAuthTokens = { + access_token: 'final-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'final-refresh-token', + }; + provider.saveTokens(tokens); + + // Verify all data is stored correctly + expect(provider.clientInformation()).toEqual(clientInfo); + expect(provider.codeVerifier()).toBe('my-code-verifier'); + expect(provider.state()).toBe(mockState); + expect(provider.tokens()).toEqual(tokens); + expect(onRedirectMock).toHaveBeenCalledWith(authUrl); + expect(provider.getSavedCallbackServer()).toBe(mockServer); + }); + }); +}); diff --git a/packages/core/src/mcp/mcp-oauth-provider.ts b/packages/core/src/mcp/mcp-oauth-provider.ts new file mode 100644 index 0000000000..daf977438c --- /dev/null +++ b/packages/core/src/mcp/mcp-oauth-provider.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +/** + * OAuth authorization response. + */ +export interface OAuthAuthorizationResponse { + code: string; + state: string; +} + +type CallbackServer = { + port: Promise; + waitForResponse: () => Promise; + close: () => Promise; +}; + +export class MCPOAuthClientProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformation; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _cbServer?: CallbackServer; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + private readonly _state?: string | undefined, + private readonly _onRedirect: (url: URL) => void = (url) => { + debugLogger.log(`Redirect to: ${url.toString()}`); + }, + ) {} + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + saveCallbackServer(server: CallbackServer): void { + this._cbServer = server; + } + + getSavedCallbackServer(): CallbackServer | undefined { + return this._cbServer; + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformation): void { + this._clientInformation = clientInformation; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + this._onRedirect(authorizationUrl); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } + + state(): string { + if (!this._state) { + throw new Error('No code state saved'); + } + return this._state; + } +} From bb6d1a2775f202edcc4ff0206cce498a52d54295 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Mon, 2 Mar 2026 13:47:21 -0800 Subject: [PATCH 17/21] feat(core): add tool name validation in TOML policy files (#19281) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/policy/config.ts | 8 +- packages/core/src/policy/toml-loader.test.ts | 286 ++++++++++++++++++- packages/core/src/policy/toml-loader.ts | 157 +++++++++- packages/core/src/tools/mcp-client.ts | 21 ++ 4 files changed, 460 insertions(+), 12 deletions(-) diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index f09db53b70..a1e337436e 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -144,7 +144,8 @@ export function getPolicyTier( */ export function formatPolicyError(error: PolicyFileError): string { const tierLabel = error.tier.toUpperCase(); - let message = `[${tierLabel}] Policy file error in ${error.fileName}:\n`; + const severityLabel = error.severity === 'warning' ? 'warning' : 'error'; + let message = `[${tierLabel}] Policy file ${severityLabel} in ${error.fileName}:\n`; message += ` ${error.message}`; if (error.details) { message += `\n${error.details}`; @@ -293,7 +294,10 @@ export async function createPolicyEngineConfig( // coreEvents has a buffer that will display these once the UI is ready if (errors.length > 0) { for (const error of errors) { - coreEvents.emitFeedback('error', formatPolicyError(error)); + coreEvents.emitFeedback( + error.severity ?? 'error', + formatPolicyError(error), + ); } } diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index 54a81771b8..30236d80c2 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -14,13 +14,26 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; import { fileURLToPath } from 'node:url'; -import { loadPoliciesFromToml } from './toml-loader.js'; +import { + loadPoliciesFromToml, + validateMcpPolicyToolNames, +} from './toml-loader.js'; import type { PolicyLoadResult } from './toml-loader.js'; import { PolicyEngine } from './policy-engine.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +/** Returns only errors (severity !== 'warning') from a PolicyLoadResult. */ +function getErrors(result: PolicyLoadResult): PolicyLoadResult['errors'] { + return result.errors.filter((e) => e.severity !== 'warning'); +} + +/** Returns only warnings (severity === 'warning') from a PolicyLoadResult. */ +function getWarnings(result: PolicyLoadResult): PolicyLoadResult['errors'] { + return result.errors.filter((e) => e.severity === 'warning'); +} + describe('policy-toml-loader', () => { let tempDir: string; @@ -189,7 +202,7 @@ priority = 100 'grep', 'read', ]); - expect(result.errors).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); }); it('should transform mcpName to composite toolName', async () => { @@ -228,7 +241,7 @@ modes = ["yolo"] expect(result.rules[0].modes).toEqual(['default', 'yolo']); expect(result.rules[1].toolName).toBe('grep'); expect(result.rules[1].modes).toEqual(['yolo']); - expect(result.errors).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); }); it('should parse and transform allow_redirection property', async () => { @@ -259,7 +272,7 @@ deny_message = "Deletion is permanent" expect(result.rules[0].toolName).toBe('rm'); expect(result.rules[0].decision).toBe(PolicyDecision.DENY); expect(result.rules[0].denyMessage).toBe('Deletion is permanent'); - expect(result.errors).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); }); it('should support modes property for Tier 4 and Tier 5 policies', async () => { @@ -547,8 +560,8 @@ commandRegex = ".*" decision = "allow" priority = 100 `); - expect(result.errors).toHaveLength(1); - const error = result.errors[0]; + expect(getErrors(result)).toHaveLength(1); + const error = getErrors(result)[0]; expect(error.errorType).toBe('rule_validation'); expect(error.details).toContain('run_shell_command'); }); @@ -576,8 +589,8 @@ argsPattern = "([a-z)" decision = "allow" priority = 100 `); - expect(result.errors).toHaveLength(1); - const error = result.errors[0]; + expect(getErrors(result)).toHaveLength(1); + const error = getErrors(result)[0]; expect(error.errorType).toBe('regex_compilation'); expect(error.message).toBe('Invalid regex pattern'); }); @@ -592,7 +605,7 @@ priority = 100 const getPolicyTier = (_dir: string) => 1; const result = await loadPoliciesFromToml([filePath], getPolicyTier); - expect(result.errors).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); expect(result.rules).toHaveLength(1); expect(result.rules[0].toolName).toBe('test-tool'); expect(result.rules[0].decision).toBe(PolicyDecision.ALLOW); @@ -612,6 +625,177 @@ priority = 100 }); }); + describe('Tool name validation', () => { + it('should warn for unrecognized tool names with suggestions', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "grob" +decision = "allow" +priority = 100 +`); + + const warnings = getWarnings(result); + expect(warnings).toHaveLength(1); + expect(warnings[0].errorType).toBe('tool_name_warning'); + expect(warnings[0].severity).toBe('warning'); + expect(warnings[0].details).toContain('Unrecognized tool name "grob"'); + expect(warnings[0].details).toContain('glob'); + // Rules should still load despite warnings + expect(result.rules).toHaveLength(1); + expect(result.rules[0].toolName).toBe('grob'); + }); + + it('should not warn for valid built-in tool names', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "glob" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "read_file" +decision = "allow" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); + expect(result.rules).toHaveLength(2); + }); + + it('should not warn for wildcard "*"', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "*" +decision = "allow" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); + }); + + it('should not warn for MCP format tool names', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "my-server__my-tool" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "my-server__*" +decision = "allow" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); + }); + + it('should not warn when mcpName is present (skips validation)', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +mcpName = "my-server" +toolName = "nonexistent" +decision = "allow" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); + }); + + it('should not warn for legacy aliases', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "search_file_content" +decision = "allow" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); + }); + + it('should not warn for discovered tool prefix', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "discovered_tool_my_custom_tool" +decision = "allow" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); + }); + + it('should warn for each invalid name in a toolName array', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = ["grob", "glob", "replce"] +decision = "allow" +priority = 100 +`); + + const warnings = getWarnings(result); + expect(warnings).toHaveLength(2); + expect(warnings[0].details).toContain('"grob"'); + expect(warnings[1].details).toContain('"replce"'); + // All rules still load + expect(result.rules).toHaveLength(3); + }); + + it('should not warn for names far from any built-in (dynamic/agent tools)', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "delegate_to_agent" +decision = "allow" +priority = 100 + +[[rule]] +toolName = "my_custom_tool" +decision = "allow" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); + expect(result.rules).toHaveLength(2); + }); + + it('should not warn for catch-all rules (no toolName)', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +decision = "deny" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(0); + expect(getErrors(result)).toHaveLength(0); + expect(result.rules).toHaveLength(1); + }); + + it('should still load rules even with warnings', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +toolName = "wrte_file" +decision = "deny" +priority = 50 + +[[rule]] +toolName = "glob" +decision = "allow" +priority = 100 +`); + + expect(getWarnings(result)).toHaveLength(1); + expect(getErrors(result)).toHaveLength(0); + expect(result.rules).toHaveLength(2); + expect(result.rules[0].toolName).toBe('wrte_file'); + expect(result.rules[1].toolName).toBe('glob'); + }); + }); + describe('Built-in Plan Mode Policy', () => { it('should allow MCP tools with readOnlyHint annotation in Plan Mode (ASK_USER, not DENY)', async () => { const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml'); @@ -779,4 +963,88 @@ priority = 100 } }); }); + + describe('validateMcpPolicyToolNames', () => { + it('should warn for MCP tool names that are likely typos', () => { + const warnings = validateMcpPolicyToolNames( + 'google-workspace', + ['people.getMe', 'calendar.list', 'calendar.get'], + [ + { + toolName: 'google-workspace__people.getxMe', + source: 'User: workspace.toml', + }, + ], + ); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('people.getxMe'); + expect(warnings[0]).toContain('google-workspace'); + expect(warnings[0]).toContain('people.getMe'); + }); + + it('should not warn for matching MCP tool names', () => { + const warnings = validateMcpPolicyToolNames( + 'google-workspace', + ['people.getMe', 'calendar.list'], + [ + { toolName: 'google-workspace__people.getMe' }, + { toolName: 'google-workspace__calendar.list' }, + ], + ); + + expect(warnings).toHaveLength(0); + }); + + it('should not warn for wildcard MCP rules', () => { + const warnings = validateMcpPolicyToolNames( + 'my-server', + ['tool1', 'tool2'], + [{ toolName: 'my-server__*' }], + ); + + expect(warnings).toHaveLength(0); + }); + + it('should not warn for rules targeting other servers', () => { + const warnings = validateMcpPolicyToolNames( + 'server-a', + ['tool1'], + [{ toolName: 'server-b__toolx' }], + ); + + expect(warnings).toHaveLength(0); + }); + + it('should not warn for tool names far from any discovered tool', () => { + const warnings = validateMcpPolicyToolNames( + 'my-server', + ['tool1', 'tool2'], + [{ toolName: 'my-server__completely_different_name' }], + ); + + expect(warnings).toHaveLength(0); + }); + + it('should skip rules without toolName', () => { + const warnings = validateMcpPolicyToolNames( + 'my-server', + ['tool1'], + [{ toolName: undefined }], + ); + + expect(warnings).toHaveLength(0); + }); + + it('should include source in warning when available', () => { + const warnings = validateMcpPolicyToolNames( + 'my-server', + ['tool1'], + [{ toolName: 'my-server__tol1', source: 'User: custom.toml' }], + ); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('User: custom.toml'); + }); + }); }); diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index df4bd3ca9e..d2a24aa100 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -13,12 +13,25 @@ import { InProcessCheckerType, } from './types.js'; import { buildArgsPatterns, isSafeRegExp } from './utils.js'; +import { + isValidToolName, + ALL_BUILTIN_TOOL_NAMES, +} from '../tools/tool-names.js'; +import { getToolSuggestion } from '../utils/tool-utils.js'; +import levenshtein from 'fast-levenshtein'; import fs from 'node:fs/promises'; import path from 'node:path'; import toml from '@iarna/toml'; import { z, type ZodError } from 'zod'; import { isNodeError } from '../utils/errors.js'; +/** + * Maximum Levenshtein distance to consider a name a likely typo of a built-in tool. + * Names further from all built-in tools are assumed to be intentional + * (e.g., dynamically registered agent tools) and are not warned about. + */ +const MAX_TYPO_DISTANCE = 3; + /** * Schema for a single policy rule in the TOML file (before transformation). */ @@ -100,7 +113,8 @@ export type PolicyFileErrorType = | 'toml_parse' | 'schema_validation' | 'rule_validation' - | 'regex_compilation'; + | 'regex_compilation' + | 'tool_name_warning'; /** * Detailed error information for policy file loading failures. @@ -114,6 +128,7 @@ export interface PolicyFileError { message: string; details?: string; suggestion?: string; + severity?: 'error' | 'warning'; } /** @@ -241,6 +256,36 @@ function validateShellCommandSyntax( return null; } +/** + * Validates that a tool name is recognized. + * Returns a warning message if the tool name is a likely typo of a built-in + * tool name, or null if valid or not close to any built-in name. + */ +function validateToolName(name: string, ruleIndex: number): string | null { + // A name that looks like an MCP tool (e.g., "re__ad") could be a typo of a + // built-in tool ("read_file"). We should let such names fall through to the + // Levenshtein distance check below. Non-MCP-like names that are valid can + // be safely skipped. + if (isValidToolName(name, { allowWildcards: true }) && !name.includes('__')) { + return null; + } + + // Only warn if the name is close to a built-in name (likely typo). + // Names that are very different from all built-in names are likely + // intentional (dynamic tools, agent tools, etc.). + const allNames = [...ALL_BUILTIN_TOOL_NAMES]; + const minDistance = Math.min( + ...allNames.map((n) => levenshtein.get(name, n)), + ); + + if (minDistance > MAX_TYPO_DISTANCE) { + return null; + } + + const suggestion = getToolSuggestion(name, allNames); + return `Rule #${ruleIndex + 1}: Unrecognized tool name "${name}".${suggestion}`; +} + /** * Transforms a priority number based on the policy tier. * Formula: tier + priority/1000 @@ -354,6 +399,35 @@ export async function loadPoliciesFromToml( } } + // Validate tool names in rules + for (let i = 0; i < tomlRules.length; i++) { + const rule = tomlRules[i]; + // Skip MCP-scoped rules — MCP tool names are server-defined and dynamic + if (rule.mcpName) continue; + + const toolNames: string[] = rule.toolName + ? Array.isArray(rule.toolName) + ? rule.toolName + : [rule.toolName] + : []; + + for (const name of toolNames) { + const warning = validateToolName(name, i); + if (warning) { + errors.push({ + filePath, + fileName: file, + tier: tierName, + ruleIndex: i, + errorType: 'tool_name_warning', + message: 'Unrecognized tool name', + details: warning, + severity: 'warning', + }); + } + } + } + // Transform rules const parsedRules: PolicyRule[] = (validationResult.data.rule ?? []) .flatMap((rule) => { @@ -439,6 +513,35 @@ export async function loadPoliciesFromToml( rules.push(...parsedRules); + // Validate tool names in safety checker rules + const tomlCheckerRules = validationResult.data.safety_checker ?? []; + for (let i = 0; i < tomlCheckerRules.length; i++) { + const checker = tomlCheckerRules[i]; + if (checker.mcpName) continue; + + const checkerToolNames: string[] = checker.toolName + ? Array.isArray(checker.toolName) + ? checker.toolName + : [checker.toolName] + : []; + + for (const name of checkerToolNames) { + const warning = validateToolName(name, i); + if (warning) { + errors.push({ + filePath, + fileName: file, + tier: tierName, + ruleIndex: i, + errorType: 'tool_name_warning', + message: 'Unrecognized tool name in safety checker', + details: warning, + severity: 'warning', + }); + } + } + } + // Transform checkers const parsedCheckers: SafetyCheckerRule[] = ( validationResult.data.safety_checker ?? [] @@ -535,3 +638,55 @@ export async function loadPoliciesFromToml( return { rules, checkers, errors }; } + +/** + * Validates MCP tool names in policy rules against actually discovered MCP tools. + * Called after an MCP server connects and its tools are discovered. + * + * For each policy rule that references the given MCP server, checks if the + * tool name matches any discovered tool. Emits warnings for likely typos + * using Levenshtein distance. + * + * @param serverName The MCP server name (e.g., "google-workspace") + * @param discoveredToolNames The tool names discovered from this server (simple names, not fully qualified) + * @param policyRules The current set of policy rules to validate against + * @returns Array of warning messages for unrecognized MCP tool names + */ +export function validateMcpPolicyToolNames( + serverName: string, + discoveredToolNames: string[], + policyRules: ReadonlyArray<{ toolName?: string; source?: string }>, +): string[] { + const prefix = `${serverName}__`; + const warnings: string[] = []; + + for (const rule of policyRules) { + if (!rule.toolName) continue; + if (!rule.toolName.startsWith(prefix)) continue; + + const toolPart = rule.toolName.slice(prefix.length); + + // Skip wildcards + if (toolPart === '*') continue; + + // Check if the tool exists + if (discoveredToolNames.includes(toolPart)) continue; + + // Tool not found — check if it's a likely typo + if (discoveredToolNames.length === 0) continue; + + const minDistance = Math.min( + ...discoveredToolNames.map((n) => levenshtein.get(toolPart, n)), + ); + + if (minDistance > MAX_TYPO_DISTANCE) continue; + + const suggestion = getToolSuggestion(toolPart, discoveredToolNames); + const source = rule.source ? ` (from ${rule.source})` : ''; + warnings.push( + `Unrecognized MCP tool "${toolPart}" for server "${serverName}"${source}.${suggestion}`, + ); + } + + return warnings; +} diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 18c2029d9e..24f93052bf 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -69,6 +69,7 @@ import { debugLogger } from '../utils/debugLogger.js'; import { type MessageBus } from '../confirmation-bus/message-bus.js'; import { coreEvents } from '../utils/events.js'; import type { ResourceRegistry } from '../resources/resource-registry.js'; +import { validateMcpPolicyToolNames } from '../policy/toml-loader.js'; import { sanitizeEnvironment, type EnvironmentSanitizationConfig, @@ -221,6 +222,23 @@ export class McpClient implements McpProgressReporter { this.toolRegistry.registerTool(tool); } this.toolRegistry.sortTools(); + + // Validate MCP tool names in policy rules against discovered tools + try { + const discoveredToolNames = tools.map((t) => t.serverToolName); + const policyRules = cliConfig.getPolicyEngine?.()?.getRules() ?? []; + const warnings = validateMcpPolicyToolNames( + this.serverName, + discoveredToolNames, + policyRules, + ); + for (const warning of warnings) { + coreEvents.emitFeedback('warning', warning); + } + } catch { + // Policy engine may not be available in all contexts (e.g. tests). + // Validation is best-effort; skip silently if unavailable. + } } /** @@ -1577,6 +1595,9 @@ export interface McpContext { ): void; setUserInteractedWithMcp?(): void; isTrustedFolder(): boolean; + getPolicyEngine?(): { + getRules(): ReadonlyArray<{ toolName?: string; source?: string }>; + }; } /** From e43b1cff58577d55f7a1a820f693faf6abc396e4 Mon Sep 17 00:00:00 2001 From: Hamdanbinhashim Date: Tue, 3 Mar 2026 03:21:52 +0530 Subject: [PATCH 18/21] docs: fix broken markdown links in main README.md (#20300) --- README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f44a2e238d..02dd4988f0 100644 --- a/README.md +++ b/README.md @@ -282,14 +282,14 @@ gemini quickly. - [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed auth configuration. -- [**Configuration Guide**](./docs/get-started/configuration.md) - Settings and +- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and customization. -- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity - tips. +- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) - + Productivity tips. ### Core Features -- [**Commands Reference**](./docs/cli/commands.md) - All slash commands +- [**Commands Reference**](./docs/reference/commands.md) - All slash commands (`/help`, `/chat`, etc). - [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own reusable commands. @@ -323,15 +323,16 @@ gemini - [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a corporate environment. - [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking. -- [**Tools API Development**](./docs/core/tools-api.md) - Create custom tools. +- [**Tools API Development**](./docs/reference/tools-api.md) - Create custom + tools. - [**Local development**](./docs/local-development.md) - Local development tooling. ### Troubleshooting & Support -- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and - solutions. -- [**FAQ**](./docs/faq.md) - Frequently asked questions. +- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common + issues and solutions. +- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions. - Use `/bug` command to report issues directly from the CLI. ### Using MCP Servers @@ -377,7 +378,8 @@ for planned features and priorities. ### Uninstall -See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions. +See the [Uninstall Guide](./docs/resources/uninstall.md) for removal +instructions. ## 📄 Legal From d05ba11a313d0aec8b26b2a479df5a711cf90ba0 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Mon, 2 Mar 2026 17:30:50 -0500 Subject: [PATCH 19/21] refactor(core): replace manual syncPlanModeTools with declarative policy rules (#20596) --- integration-tests/plan-mode.test.ts | 5 +- packages/core/src/config/config.test.ts | 149 +++--------------- packages/core/src/config/config.ts | 52 +----- packages/core/src/policy/policies/plan.toml | 52 ++++-- .../core/src/policy/policies/read-only.toml | 23 +-- packages/core/src/policy/policies/write.toml | 23 +-- packages/core/src/policy/policies/yolo.toml | 32 ++-- .../core/src/policy/policy-engine.test.ts | 76 +++++++++ packages/core/src/tools/ask-user.ts | 4 +- packages/core/src/tools/enter-plan-mode.ts | 4 +- packages/core/src/tools/exit-plan-mode.ts | 4 +- 11 files changed, 198 insertions(+), 226 deletions(-) diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index a4af47252c..8709aac189 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -182,10 +182,7 @@ describe('Plan Mode', () => { 'I want to perform a complex refactoring. Please enter plan mode so we can design it first.', }); - const enterPlanCallFound = await rig.waitForToolCall( - 'enter_plan_mode', - 10000, - ); + const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode'); expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe( true, ); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index ad8af8656c..83ee54f8e0 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -223,8 +223,6 @@ import type { ModelConfigService, ModelConfigServiceConfig, } from '../services/modelConfigService.js'; -import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; -import { EnterPlanModeTool } from '../tools/enter-plan-mode.js'; import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js'; vi.mock('../core/baseLlmClient.js'); @@ -1204,6 +1202,28 @@ describe('Server Config (config.ts)', () => { expect(SubAgentToolMock).not.toHaveBeenCalled(); }); + it('should register EnterPlanModeTool and ExitPlanModeTool when plan is enabled', async () => { + const params: ConfigParameters = { + ...baseParams, + plan: true, + }; + const config = new Config(params); + + await config.initialize(); + + const registerToolMock = ( + (await vi.importMock('../tools/tool-registry')) as { + ToolRegistry: { prototype: { registerTool: Mock } }; + } + ).ToolRegistry.prototype.registerTool; + + const registeredTools = registerToolMock.mock.calls.map( + (call) => call[0].constructor.name, + ); + expect(registeredTools).toContain('EnterPlanModeTool'); + expect(registeredTools).toContain('ExitPlanModeTool'); + }); + describe('with minified tool class names', () => { beforeEach(() => { Object.defineProperty( @@ -2961,131 +2981,6 @@ describe('Plans Directory Initialization', () => { expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, { recursive: true, }); - - const context = config.getWorkspaceContext(); - expect(context.getDirectories()).not.toContain(plansDir); - }); -}); - -describe('syncPlanModeTools', () => { - const baseParams: ConfigParameters = { - sessionId: 'test-session', - targetDir: '.', - debugMode: false, - model: 'test-model', - cwd: '.', - }; - - it('should register ExitPlanModeTool and unregister EnterPlanModeTool when in PLAN mode', async () => { - const config = new Config({ - ...baseParams, - approvalMode: ApprovalMode.PLAN, - }); - const registry = new ToolRegistry(config, config.getMessageBus()); - vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); - - const registerSpy = vi.spyOn(registry, 'registerTool'); - const unregisterSpy = vi.spyOn(registry, 'unregisterTool'); - const getToolSpy = vi.spyOn(registry, 'getTool'); - - getToolSpy.mockImplementation((name) => { - if (name === 'enter_plan_mode') - return new EnterPlanModeTool(config, config.getMessageBus()); - return undefined; - }); - - config.syncPlanModeTools(); - - expect(unregisterSpy).toHaveBeenCalledWith('enter_plan_mode'); - expect(registerSpy).toHaveBeenCalledWith(expect.anything()); - const registeredTool = registerSpy.mock.calls[0][0]; - const { ExitPlanModeTool } = await import('../tools/exit-plan-mode.js'); - expect(registeredTool).toBeInstanceOf(ExitPlanModeTool); - }); - - it('should register EnterPlanModeTool and unregister ExitPlanModeTool when NOT in PLAN mode and experimental.plan is enabled', async () => { - const config = new Config({ - ...baseParams, - approvalMode: ApprovalMode.DEFAULT, - plan: true, - }); - const registry = new ToolRegistry(config, config.getMessageBus()); - vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); - - const registerSpy = vi.spyOn(registry, 'registerTool'); - const unregisterSpy = vi.spyOn(registry, 'unregisterTool'); - const getToolSpy = vi.spyOn(registry, 'getTool'); - - getToolSpy.mockImplementation((name) => { - if (name === 'exit_plan_mode') - return new ExitPlanModeTool(config, config.getMessageBus()); - return undefined; - }); - - config.syncPlanModeTools(); - - expect(unregisterSpy).toHaveBeenCalledWith('exit_plan_mode'); - expect(registerSpy).toHaveBeenCalledWith(expect.anything()); - const registeredTool = registerSpy.mock.calls[0][0]; - const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js'); - expect(registeredTool).toBeInstanceOf(EnterPlanModeTool); - }); - - it('should NOT register EnterPlanModeTool when experimental.plan is disabled', async () => { - const config = new Config({ - ...baseParams, - approvalMode: ApprovalMode.DEFAULT, - plan: false, - }); - const registry = new ToolRegistry(config, config.getMessageBus()); - vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); - - const registerSpy = vi.spyOn(registry, 'registerTool'); - vi.spyOn(registry, 'getTool').mockReturnValue(undefined); - - config.syncPlanModeTools(); - - const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js'); - const registeredTool = registerSpy.mock.calls.find( - (call) => call[0] instanceof EnterPlanModeTool, - ); - expect(registeredTool).toBeUndefined(); - }); - - it('should NOT register EnterPlanModeTool when in YOLO mode, even if plan is enabled', async () => { - const config = new Config({ - ...baseParams, - approvalMode: ApprovalMode.YOLO, - plan: true, - }); - const registry = new ToolRegistry(config, config.getMessageBus()); - vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); - - const registerSpy = vi.spyOn(registry, 'registerTool'); - vi.spyOn(registry, 'getTool').mockReturnValue(undefined); - - config.syncPlanModeTools(); - - const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js'); - const registeredTool = registerSpy.mock.calls.find( - (call) => call[0] instanceof EnterPlanModeTool, - ); - expect(registeredTool).toBeUndefined(); - }); - - it('should call geminiClient.setTools if initialized', async () => { - const config = new Config(baseParams); - const registry = new ToolRegistry(config, config.getMessageBus()); - vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); - const client = config.getGeminiClient(); - vi.spyOn(client, 'isInitialized').mockReturnValue(true); - const setToolsSpy = vi - .spyOn(client, 'setTools') - .mockResolvedValue(undefined); - - config.syncPlanModeTools(); - - expect(setToolsSpy).toHaveBeenCalled(); }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 87633d35b6..1a5c14b12c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -370,10 +370,6 @@ import { McpClientManager } from '../tools/mcp-client-manager.js'; import { type McpContext } from '../tools/mcp-client.js'; import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js'; import { getErrorMessage } from '../utils/errors.js'; -import { - ENTER_PLAN_MODE_TOOL_NAME, - EXIT_PLAN_MODE_TOOL_NAME, -} from '../tools/tool-names.js'; export type { FileFilteringOptions }; export { @@ -1172,7 +1168,6 @@ export class Config implements McpContext { } await this.geminiClient.initialize(); - this.syncPlanModeTools(); this.initialized = true; } @@ -1998,52 +1993,15 @@ export class Config implements McpContext { (currentMode === ApprovalMode.YOLO || mode === ApprovalMode.YOLO); if (isPlanModeTransition || isYoloModeTransition) { - this.syncPlanModeTools(); + if (this.geminiClient?.isInitialized()) { + this.geminiClient.setTools().catch((err) => { + debugLogger.error('Failed to update tools', err); + }); + } this.updateSystemInstructionIfInitialized(); } } - /** - * Synchronizes enter/exit plan mode tools based on current mode. - */ - syncPlanModeTools(): void { - const registry = this.getToolRegistry(); - if (!registry) { - return; - } - const approvalMode = this.getApprovalMode(); - const isPlanMode = approvalMode === ApprovalMode.PLAN; - const isYoloMode = approvalMode === ApprovalMode.YOLO; - - if (isPlanMode) { - if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { - registry.unregisterTool(ENTER_PLAN_MODE_TOOL_NAME); - } - if (!registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) { - registry.registerTool(new ExitPlanModeTool(this, this.messageBus)); - } - } else { - if (registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) { - registry.unregisterTool(EXIT_PLAN_MODE_TOOL_NAME); - } - if (this.planEnabled && !isYoloMode) { - if (!registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { - registry.registerTool(new EnterPlanModeTool(this, this.messageBus)); - } - } else { - if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { - registry.unregisterTool(ENTER_PLAN_MODE_TOOL_NAME); - } - } - } - - if (this.geminiClient?.isInitialized()) { - this.geminiClient.setTools().catch((err) => { - debugLogger.error('Failed to update tools', err); - }); - } - } - /** * Logs the duration of the current approval mode. */ diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index a490e589b0..1af21ba9b6 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -5,20 +5,21 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) -# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) +# - Extension policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Workspace policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - User policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) +# - Admin policies (TOML): 5 + priority/1000 (e.g., priority 100 → 5.100) # -# This ensures Admin > User > Workspace > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Extension > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 3.x): -# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 3.9: MCP servers excluded list (security: persistent server blocks) -# 3.4: Command line flag --exclude-tools (explicit temporary blocks) -# 3.3: Command line flag --allowed-tools (explicit temporary allows) -# 3.2: MCP servers with trust=true (persistent trusted servers) -# 3.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 4.x): +# 4.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 4.9: MCP servers excluded list (security: persistent server blocks) +# 4.4: Command line flag --exclude-tools (explicit temporary blocks) +# 4.3: Command line flag --allowed-tools (explicit temporary allows) +# 4.2: MCP servers with trust=true (persistent trusted servers) +# 4.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) @@ -26,6 +27,33 @@ # 70: Plan mode explicit ALLOW override (becomes 1.070 in default tier) # 999: YOLO mode allow-all (becomes 1.999 in default tier) +# Mode Transitions (into/out of Plan Mode) + +[[rule]] +toolName = "enter_plan_mode" +decision = "ask_user" +priority = 50 + +[[rule]] +toolName = "enter_plan_mode" +decision = "deny" +priority = 70 +modes = ["plan"] +deny_message = "You are already in Plan Mode." + +[[rule]] +toolName = "exit_plan_mode" +decision = "ask_user" +priority = 70 +modes = ["plan"] + +[[rule]] +toolName = "exit_plan_mode" +decision = "deny" +priority = 50 +deny_message = "You are not currently in Plan Mode. Use enter_plan_mode first to design a plan." + + # Catch-All: Deny everything by default in Plan mode. [[rule]] @@ -50,7 +78,7 @@ priority = 70 modes = ["plan"] [[rule]] -toolName = ["ask_user", "exit_plan_mode", "save_memory"] +toolName = ["ask_user", "save_memory"] decision = "ask_user" priority = 70 modes = ["plan"] diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index 1688d5108c..c9c96923e7 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -5,20 +5,21 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) -# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) +# - Extension policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Workspace policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - User policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) +# - Admin policies (TOML): 5 + priority/1000 (e.g., priority 100 → 5.100) # -# This ensures Admin > User > Workspace > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Extension > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 3.x): -# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 3.9: MCP servers excluded list (security: persistent server blocks) -# 3.4: Command line flag --exclude-tools (explicit temporary blocks) -# 3.3: Command line flag --allowed-tools (explicit temporary allows) -# 3.2: MCP servers with trust=true (persistent trusted servers) -# 3.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 4.x): +# 4.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 4.9: MCP servers excluded list (security: persistent server blocks) +# 4.4: Command line flag --exclude-tools (explicit temporary blocks) +# 4.3: Command line flag --allowed-tools (explicit temporary allows) +# 4.2: MCP servers with trust=true (persistent trusted servers) +# 4.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 47cd9c98ae..c24f6dfee3 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -5,20 +5,21 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) -# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) +# - Extension policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Workspace policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - User policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) +# - Admin policies (TOML): 5 + priority/1000 (e.g., priority 100 → 5.100) # -# This ensures Admin > User > Workspace > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Extension > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 3.x): -# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 3.9: MCP servers excluded list (security: persistent server blocks) -# 3.4: Command line flag --exclude-tools (explicit temporary blocks) -# 3.3: Command line flag --allowed-tools (explicit temporary allows) -# 3.2: MCP servers with trust=true (persistent trusted servers) -# 3.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 4.x): +# 4.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 4.9: MCP servers excluded list (security: persistent server blocks) +# 4.4: Command line flag --exclude-tools (explicit temporary blocks) +# 4.3: Command line flag --allowed-tools (explicit temporary allows) +# 4.2: MCP servers with trust=true (persistent trusted servers) +# 4.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/yolo.toml b/packages/core/src/policy/policies/yolo.toml index 332334db7c..d326e163f5 100644 --- a/packages/core/src/policy/policies/yolo.toml +++ b/packages/core/src/policy/policies/yolo.toml @@ -5,20 +5,21 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) -# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) +# - Extension policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Workspace policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - User policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) +# - Admin policies (TOML): 5 + priority/1000 (e.g., priority 100 → 5.100) # -# This ensures Admin > User > Workspace > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Extension > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 3.x): -# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 3.9: MCP servers excluded list (security: persistent server blocks) -# 3.4: Command line flag --exclude-tools (explicit temporary blocks) -# 3.3: Command line flag --allowed-tools (explicit temporary allows) -# 3.2: MCP servers with trust=true (persistent trusted servers) -# 3.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 4.x): +# 4.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 4.9: MCP servers excluded list (security: persistent server blocks) +# 4.4: Command line flag --exclude-tools (explicit temporary blocks) +# 4.3: Command line flag --allowed-tools (explicit temporary allows) +# 4.2: MCP servers with trust=true (persistent trusted servers) +# 4.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) @@ -36,6 +37,15 @@ decision = "ask_user" priority = 999 modes = ["yolo"] +# Plan mode transitions are blocked in YOLO mode to maintain state consistency +# and because planning currently requires human interaction (plan approval), +# which conflicts with YOLO's autonomous nature. +[[rule]] +toolName = ["enter_plan_mode", "exit_plan_mode"] +decision = "deny" +priority = 999 +modes = ["yolo"] + # Allow everything else in YOLO mode [[rule]] decision = "allow" diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index f93c9ad3b8..4c9b9cbfcd 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -2808,6 +2808,82 @@ describe('PolicyEngine', () => { 'Execution of scripts (including those from skills) is blocked', ); }); + + it('should deny enter_plan_mode when already in PLAN mode', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'enter_plan_mode', + decision: PolicyDecision.DENY, + priority: 70, + modes: [ApprovalMode.PLAN], + denyMessage: 'You are already in Plan Mode.', + }, + ]; + + engine = new PolicyEngine({ + rules, + approvalMode: ApprovalMode.PLAN, + }); + + const result = await engine.check({ name: 'enter_plan_mode' }, undefined); + expect(result.decision).toBe(PolicyDecision.DENY); + expect(result.rule?.denyMessage).toBe('You are already in Plan Mode.'); + }); + + it('should deny exit_plan_mode when in DEFAULT mode', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'exit_plan_mode', + decision: PolicyDecision.DENY, + priority: 10, + modes: [ApprovalMode.DEFAULT], + denyMessage: 'You are not in Plan Mode.', + }, + ]; + + engine = new PolicyEngine({ + rules, + approvalMode: ApprovalMode.DEFAULT, + }); + + const result = await engine.check({ name: 'exit_plan_mode' }, undefined); + expect(result.decision).toBe(PolicyDecision.DENY); + expect(result.rule?.denyMessage).toBe('You are not in Plan Mode.'); + }); + + it('should deny both plan tools in YOLO mode', async () => { + const rules: PolicyRule[] = [ + { + toolName: 'enter_plan_mode', + decision: PolicyDecision.DENY, + priority: 999, + modes: [ApprovalMode.YOLO], + }, + { + toolName: 'exit_plan_mode', + decision: PolicyDecision.DENY, + priority: 999, + modes: [ApprovalMode.YOLO], + }, + ]; + + engine = new PolicyEngine({ + rules, + approvalMode: ApprovalMode.YOLO, + }); + + const resultEnter = await engine.check( + { name: 'enter_plan_mode' }, + undefined, + ); + expect(resultEnter.decision).toBe(PolicyDecision.DENY); + + const resultExit = await engine.check( + { name: 'exit_plan_mode' }, + undefined, + ); + expect(resultExit.decision).toBe(PolicyDecision.DENY); + }); }); describe('removeRulesByTier', () => { diff --git a/packages/core/src/tools/ask-user.ts b/packages/core/src/tools/ask-user.ts index 6dbec43dda..621d4c10d1 100644 --- a/packages/core/src/tools/ask-user.ts +++ b/packages/core/src/tools/ask-user.ts @@ -28,9 +28,11 @@ export class AskUserTool extends BaseDeclarativeTool< AskUserParams, ToolResult > { + static readonly Name = ASK_USER_TOOL_NAME; + constructor(messageBus: MessageBus) { super( - ASK_USER_TOOL_NAME, + AskUserTool.Name, ASK_USER_DISPLAY_NAME, ASK_USER_DEFINITION.base.description!, Kind.Communicate, diff --git a/packages/core/src/tools/enter-plan-mode.ts b/packages/core/src/tools/enter-plan-mode.ts index 9e1bed23a6..d52c721aae 100644 --- a/packages/core/src/tools/enter-plan-mode.ts +++ b/packages/core/src/tools/enter-plan-mode.ts @@ -27,12 +27,14 @@ export class EnterPlanModeTool extends BaseDeclarativeTool< EnterPlanModeParams, ToolResult > { + static readonly Name = ENTER_PLAN_MODE_TOOL_NAME; + constructor( private config: Config, messageBus: MessageBus, ) { super( - ENTER_PLAN_MODE_TOOL_NAME, + EnterPlanModeTool.Name, 'Enter Plan Mode', ENTER_PLAN_MODE_DEFINITION.base.description!, Kind.Plan, diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index 1facdcbf7c..442b00e5cb 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -35,6 +35,8 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< ExitPlanModeParams, ToolResult > { + static readonly Name = EXIT_PLAN_MODE_TOOL_NAME; + constructor( private config: Config, messageBus: MessageBus, @@ -42,7 +44,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< const plansDir = config.storage.getPlansDir(); const definition = getExitPlanModeDefinition(plansDir); super( - EXIT_PLAN_MODE_TOOL_NAME, + ExitPlanModeTool.Name, 'Exit Plan Mode', definition.base.description!, Kind.Plan, From 3f7ef816f17bfbb617479108684a1e579661a7d5 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 2 Mar 2026 22:36:58 +0000 Subject: [PATCH 20/21] fix(core): increase default headers timeout to 5 minutes (#20890) --- packages/core/src/utils/fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index e0bb1f3378..b3df053614 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -8,7 +8,7 @@ import { getErrorMessage, isNodeError } from './errors.js'; import { URL } from 'node:url'; import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; -const DEFAULT_HEADERS_TIMEOUT = 60000; // 60 seconds +const DEFAULT_HEADERS_TIMEOUT = 300000; // 5 minutes const DEFAULT_BODY_TIMEOUT = 300000; // 5 minutes // Configure default global dispatcher with higher timeouts From 06ddfa5c4cfcb138ffed10221ba10925fa8e4bad Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Mon, 2 Mar 2026 17:44:49 -0500 Subject: [PATCH 21/21] feat(admin): enable 30 day default retention for chat history & remove warning (#20853) --- docs/cli/settings.md | 4 +- docs/reference/configuration.md | 9 +- integration-tests/json-output.test.ts | 4 +- packages/cli/src/config/settings.ts | 3 - packages/cli/src/config/settingsSchema.ts | 14 +- packages/cli/src/ui/AppContainer.tsx | 33 +-- .../cli/src/ui/components/DialogManager.tsx | 51 ---- .../SessionRetentionWarningDialog.test.tsx | 119 ---------- .../SessionRetentionWarningDialog.tsx | 78 ------- ...essionRetentionWarningDialog.test.tsx.snap | 21 -- .../cli/src/ui/contexts/UIStateContext.tsx | 2 - .../ui/hooks/useSessionRetentionCheck.test.ts | 217 ------------------ .../src/ui/hooks/useSessionRetentionCheck.ts | 70 ------ schemas/settings.schema.json | 14 +- 14 files changed, 16 insertions(+), 623 deletions(-) delete mode 100644 packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx delete mode 100644 packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx delete mode 100644 packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap delete mode 100644 packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts delete mode 100644 packages/cli/src/ui/hooks/useSessionRetentionCheck.ts diff --git a/docs/cli/settings.md b/docs/cli/settings.md index faf3fca3f0..571d90aaf6 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -32,8 +32,8 @@ they appear in the UI. | Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` | | Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | -| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | -| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` | +| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` | ### Output diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index a6c9ddccfd..524b00e00f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -159,12 +159,12 @@ their corresponding top-level category object in your `settings.json` file. - **`general.sessionRetention.enabled`** (boolean): - **Description:** Enable automatic session cleanup - - **Default:** `false` + - **Default:** `true` - **`general.sessionRetention.maxAge`** (string): - **Description:** Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") - - **Default:** `undefined` + - **Default:** `"30d"` - **`general.sessionRetention.maxCount`** (number): - **Description:** Alternative: Maximum number of sessions to keep (most @@ -175,11 +175,6 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Minimum retention period (safety limit, defaults to "1d") - **Default:** `"1d"` -- **`general.sessionRetention.warningAcknowledged`** (boolean): - - **Description:** INTERNAL: Whether the user has acknowledged the session - retention warning - - **Default:** `false` - #### `output` - **`output.format`** (enum): diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 215cf21226..473b966d5a 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -81,7 +81,9 @@ describe('JSON output', () => { const message = (thrown as Error).message; // Use a regex to find the first complete JSON object in the string - const jsonMatch = message.match(/{[\s\S]*}/); + // We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid + // matching random error objects printed to stderr (like ENOENT). + const jsonMatch = message.match(/{\s*"[\s\S]*}/); // Fail if no JSON-like text was found expect( diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 657968a3b6..4e9faf5767 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -185,9 +185,6 @@ export interface SessionRetentionSettings { /** Minimum retention period (safety limit, defaults to "1d") */ minRetention?: string; - - /** INTERNAL: Whether the user has acknowledged the session retention warning */ - warningAcknowledged?: boolean; } export interface SettingsError { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 599c8e586b..38b71e433f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -339,7 +339,7 @@ const SETTINGS_SCHEMA = { label: 'Enable Session Cleanup', category: 'General', requiresRestart: false, - default: false, + default: true as boolean, description: 'Enable automatic session cleanup', showInDialog: true, }, @@ -348,7 +348,7 @@ const SETTINGS_SCHEMA = { label: 'Keep chat history', category: 'General', requiresRestart: false, - default: undefined as string | undefined, + default: '30d' as string, description: 'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")', showInDialog: true, @@ -372,16 +372,6 @@ const SETTINGS_SCHEMA = { description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, showInDialog: false, }, - warningAcknowledged: { - type: 'boolean', - label: 'Warning Acknowledged', - category: 'General', - requiresRestart: false, - default: false, - showInDialog: false, - description: - 'INTERNAL: Whether the user has acknowledged the session retention warning', - }, }, description: 'Settings for automatic session cleanup.', }, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d42cad8495..4f8d739340 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -146,7 +146,6 @@ import { requestConsentInteractive } from '../config/extensions/consent.js'; import { useSessionBrowser } from './hooks/useSessionBrowser.js'; import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; -import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; @@ -1548,28 +1547,6 @@ Logging in with Google... Restarting Gemini CLI to continue. useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); - const handleAutoEnableRetention = useCallback(() => { - const userSettings = settings.forScope(SettingScope.User).settings; - const currentRetention = userSettings.general?.sessionRetention ?? {}; - - settings.setValue(SettingScope.User, 'general.sessionRetention', { - ...currentRetention, - enabled: true, - maxAge: '30d', - warningAcknowledged: true, - }); - }, [settings]); - - const { - shouldShowWarning: shouldShowRetentionWarning, - checkComplete: retentionCheckComplete, - sessionsToDeleteCount, - } = useSessionRetentionCheck( - config, - settings.merged, - handleAutoEnableRetention, - ); - const tabFocusTimeoutRef = useRef(null); useEffect(() => { @@ -2015,7 +1992,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const nightly = props.version.includes('nightly'); const dialogsVisible = - (shouldShowRetentionWarning && retentionCheckComplete) || + shouldShowIdePrompt || shouldShowIdePrompt || isFolderTrustDialogOpen || isPolicyUpdateDialogOpen || @@ -2202,9 +2179,7 @@ Logging in with Google... Restarting Gemini CLI to continue. history: historyManager.history, historyManager, isThemeDialogOpen, - shouldShowRetentionWarning: - shouldShowRetentionWarning && retentionCheckComplete, - sessionsToDeleteCount: sessionsToDeleteCount ?? 0, + themeError, isAuthenticating, isConfigInitialized, @@ -2334,9 +2309,7 @@ Logging in with Google... Restarting Gemini CLI to continue. }), [ isThemeDialogOpen, - shouldShowRetentionWarning, - retentionCheckComplete, - sessionsToDeleteCount, + themeError, isAuthenticating, isConfigInitialized, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 3cca19b0b0..c86a4ba8d3 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -37,9 +37,6 @@ import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { NewAgentsNotification } from './NewAgentsNotification.js'; import { AgentConfigDialog } from './AgentConfigDialog.js'; -import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; -import { useCallback } from 'react'; -import { SettingScope } from '../../config/settings.js'; import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; interface DialogManagerProps { @@ -62,56 +59,8 @@ export const DialogManager = ({ terminalHeight, staticExtraHeight, terminalWidth: uiTerminalWidth, - shouldShowRetentionWarning, - sessionsToDeleteCount, } = uiState; - const handleKeep120Days = useCallback(() => { - settings.setValue( - SettingScope.User, - 'general.sessionRetention.warningAcknowledged', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.enabled', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.maxAge', - '120d', - ); - }, [settings]); - - const handleKeep30Days = useCallback(() => { - settings.setValue( - SettingScope.User, - 'general.sessionRetention.warningAcknowledged', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.enabled', - true, - ); - settings.setValue( - SettingScope.User, - 'general.sessionRetention.maxAge', - '30d', - ); - }, [settings]); - - if (shouldShowRetentionWarning && sessionsToDeleteCount !== undefined) { - return ( - - ); - } - if (uiState.adminSettingsChanged) { return ; } diff --git a/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx b/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx deleted file mode 100644 index ec3157fa89..0000000000 --- a/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - * - * @license - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { renderWithProviders } from '../../test-utils/render.js'; -import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; -import { waitFor } from '../../test-utils/async.js'; -import { act } from 'react'; - -// Helper to write to stdin -const writeKey = (stdin: { write: (data: string) => void }, key: string) => { - act(() => { - stdin.write(key); - }); -}; - -describe('SessionRetentionWarningDialog', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('renders correctly with warning message and session count', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( - , - ); - await waitUntilReady(); - - expect(lastFrame()).toContain('Keep chat history'); - expect(lastFrame()).toContain( - 'introducing a limit on how long chat sessions are stored', - ); - expect(lastFrame()).toContain('Keep for 30 days (Recommended)'); - expect(lastFrame()).toContain('42 sessions will be deleted'); - expect(lastFrame()).toContain('Keep for 120 days'); - expect(lastFrame()).toContain('No sessions will be deleted at this time'); - }); - - it('handles pluralization correctly for 1 session', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( - , - ); - await waitUntilReady(); - - expect(lastFrame()).toContain('1 session will be deleted'); - }); - - it('defaults to "Keep for 120 days" when there are sessions to delete', async () => { - const onKeep120Days = vi.fn(); - const onKeep30Days = vi.fn(); - - const { stdin, waitUntilReady } = renderWithProviders( - , - ); - await waitUntilReady(); - - // Initial selection should be "Keep for 120 days" (index 1) because count > 0 - // Pressing Enter immediately should select it. - writeKey(stdin, '\r'); - - await waitFor(() => { - expect(onKeep120Days).toHaveBeenCalled(); - expect(onKeep30Days).not.toHaveBeenCalled(); - }); - }); - - it('calls onKeep30Days when "Keep for 30 days" is explicitly selected (from 120 days default)', async () => { - const onKeep120Days = vi.fn(); - const onKeep30Days = vi.fn(); - - const { stdin, waitUntilReady } = renderWithProviders( - , - ); - await waitUntilReady(); - - // Default is index 1 (120 days). Move UP to index 0 (30 days). - writeKey(stdin, '\x1b[A'); // Up arrow - writeKey(stdin, '\r'); - - await waitFor(() => { - expect(onKeep30Days).toHaveBeenCalled(); - expect(onKeep120Days).not.toHaveBeenCalled(); - }); - }); - - it('should match snapshot', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( - , - ); - await waitUntilReady(); - - // Initial render - expect(lastFrame()).toMatchSnapshot(); - }); -}); diff --git a/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx b/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx deleted file mode 100644 index cd0477105c..0000000000 --- a/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { Box, Text } from 'ink'; -import { theme } from '../semantic-colors.js'; -import { - RadioButtonSelect, - type RadioSelectItem, -} from './shared/RadioButtonSelect.js'; - -interface SessionRetentionWarningDialogProps { - onKeep120Days: () => void; - onKeep30Days: () => void; - sessionsToDeleteCount: number; -} - -export const SessionRetentionWarningDialog = ({ - onKeep120Days, - onKeep30Days, - sessionsToDeleteCount, -}: SessionRetentionWarningDialogProps) => { - const options: Array void>> = [ - { - label: 'Keep for 30 days (Recommended)', - value: onKeep30Days, - key: '30days', - sublabel: `${sessionsToDeleteCount} session${ - sessionsToDeleteCount === 1 ? '' : 's' - } will be deleted`, - }, - { - label: 'Keep for 120 days', - value: onKeep120Days, - key: '120days', - sublabel: 'No sessions will be deleted at this time', - }, - ]; - - return ( - - - Keep chat history - - - - - To keep your workspace clean, we are introducing a limit on how long - chat sessions are stored. Please choose a retention period for your - existing chats: - - - - - action()} - initialIndex={1} - /> - - - - - Set a custom limit /settings{' '} - and change "Keep chat history". - - - - ); -}; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap deleted file mode 100644 index 95f1b4760c..0000000000 --- a/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`SessionRetentionWarningDialog > should match snapshot 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ Keep chat history │ -│ │ -│ To keep your workspace clean, we are introducing a limit on how long chat sessions are stored. │ -│ Please choose a retention period for your existing chats: │ -│ │ -│ │ -│ 1. Keep for 30 days (Recommended) │ -│ 123 sessions will be deleted │ -│ ● 2. Keep for 120 days │ -│ No sessions will be deleted at this time │ -│ │ -│ Set a custom limit /settings and change "Keep chat history". │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" -`; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 554cff34f9..ea9025aa6b 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -107,8 +107,6 @@ export interface UIState { history: HistoryItem[]; historyManager: UseHistoryManagerReturn; isThemeDialogOpen: boolean; - shouldShowRetentionWarning: boolean; - sessionsToDeleteCount: number; themeError: string | null; isAuthenticating: boolean; isConfigInitialized: boolean; diff --git a/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts b/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts deleted file mode 100644 index 67e5efbc6b..0000000000 --- a/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook } from '../../test-utils/render.js'; -import { useSessionRetentionCheck } from './useSessionRetentionCheck.js'; -import { type Config } from '@google/gemini-cli-core'; -import type { Settings } from '../../config/settingsSchema.js'; -import { waitFor } from '../../test-utils/async.js'; - -// Mock utils -const mockGetAllSessionFiles = vi.fn(); -const mockIdentifySessionsToDelete = vi.fn(); - -vi.mock('../../utils/sessionUtils.js', () => ({ - getAllSessionFiles: () => mockGetAllSessionFiles(), -})); - -vi.mock('../../utils/sessionCleanup.js', () => ({ - identifySessionsToDelete: () => mockIdentifySessionsToDelete(), - DEFAULT_MIN_RETENTION: '30d', -})); - -describe('useSessionRetentionCheck', () => { - const mockConfig = { - storage: { - getProjectTempDir: () => '/mock/project/temp/dir', - }, - getSessionId: () => 'mock-session-id', - } as unknown as Config; - - beforeEach(() => { - vi.resetAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should show warning if enabled is true but maxAge is undefined', async () => { - const settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: undefined, - warningAcknowledged: false, - }, - }, - } as unknown as Settings; - - mockGetAllSessionFiles.mockResolvedValue(['session1.json']); - mockIdentifySessionsToDelete.mockResolvedValue(['session1.json']); - - const { result } = renderHook(() => - useSessionRetentionCheck(mockConfig, settings), - ); - - await waitFor(() => { - expect(result.current.checkComplete).toBe(true); - expect(result.current.shouldShowWarning).toBe(true); - expect(mockGetAllSessionFiles).toHaveBeenCalled(); - expect(mockIdentifySessionsToDelete).toHaveBeenCalled(); - }); - }); - - it('should not show warning if warningAcknowledged is true', async () => { - const settings = { - general: { - sessionRetention: { - warningAcknowledged: true, - }, - }, - } as unknown as Settings; - - const { result } = renderHook(() => - useSessionRetentionCheck(mockConfig, settings), - ); - - await waitFor(() => { - expect(result.current.checkComplete).toBe(true); - expect(result.current.shouldShowWarning).toBe(false); - expect(mockGetAllSessionFiles).not.toHaveBeenCalled(); - expect(mockIdentifySessionsToDelete).not.toHaveBeenCalled(); - }); - }); - - it('should not show warning if retention is already enabled', async () => { - const settings = { - general: { - sessionRetention: { - enabled: true, - maxAge: '30d', // Explicitly enabled with non-default - }, - }, - } as unknown as Settings; - - const { result } = renderHook(() => - useSessionRetentionCheck(mockConfig, settings), - ); - - await waitFor(() => { - expect(result.current.checkComplete).toBe(true); - expect(result.current.shouldShowWarning).toBe(false); - expect(mockGetAllSessionFiles).not.toHaveBeenCalled(); - expect(mockIdentifySessionsToDelete).not.toHaveBeenCalled(); - }); - }); - - it('should show warning if sessions to delete exist', async () => { - const settings = { - general: { - sessionRetention: { - enabled: false, - warningAcknowledged: false, - }, - }, - } as unknown as Settings; - - mockGetAllSessionFiles.mockResolvedValue([ - 'session1.json', - 'session2.json', - ]); - mockIdentifySessionsToDelete.mockResolvedValue(['session1.json']); // 1 session to delete - - const { result } = renderHook(() => - useSessionRetentionCheck(mockConfig, settings), - ); - - await waitFor(() => { - expect(result.current.checkComplete).toBe(true); - expect(result.current.shouldShowWarning).toBe(true); - expect(result.current.sessionsToDeleteCount).toBe(1); - expect(mockGetAllSessionFiles).toHaveBeenCalled(); - expect(mockIdentifySessionsToDelete).toHaveBeenCalled(); - }); - }); - - it('should call onAutoEnable if no sessions to delete and currently disabled', async () => { - const settings = { - general: { - sessionRetention: { - enabled: false, - warningAcknowledged: false, - }, - }, - } as unknown as Settings; - - mockGetAllSessionFiles.mockResolvedValue(['session1.json']); - mockIdentifySessionsToDelete.mockResolvedValue([]); // 0 sessions to delete - - const onAutoEnable = vi.fn(); - - const { result } = renderHook(() => - useSessionRetentionCheck(mockConfig, settings, onAutoEnable), - ); - - await waitFor(() => { - expect(result.current.checkComplete).toBe(true); - expect(result.current.shouldShowWarning).toBe(false); - expect(onAutoEnable).toHaveBeenCalled(); - }); - }); - - it('should not show warning if no sessions to delete', async () => { - const settings = { - general: { - sessionRetention: { - enabled: false, - warningAcknowledged: false, - }, - }, - } as unknown as Settings; - - mockGetAllSessionFiles.mockResolvedValue([ - 'session1.json', - 'session2.json', - ]); - mockIdentifySessionsToDelete.mockResolvedValue([]); // 0 sessions to delete - - const { result } = renderHook(() => - useSessionRetentionCheck(mockConfig, settings), - ); - - await waitFor(() => { - expect(result.current.checkComplete).toBe(true); - expect(result.current.shouldShowWarning).toBe(false); - expect(result.current.sessionsToDeleteCount).toBe(0); - expect(mockGetAllSessionFiles).toHaveBeenCalled(); - expect(mockIdentifySessionsToDelete).toHaveBeenCalled(); - }); - }); - - it('should handle errors gracefully (assume no warning)', async () => { - const settings = { - general: { - sessionRetention: { - enabled: false, - warningAcknowledged: false, - }, - }, - } as unknown as Settings; - - mockGetAllSessionFiles.mockRejectedValue(new Error('FS Error')); - - const { result } = renderHook(() => - useSessionRetentionCheck(mockConfig, settings), - ); - - await waitFor(() => { - expect(result.current.checkComplete).toBe(true); - expect(result.current.shouldShowWarning).toBe(false); - }); - }); -}); diff --git a/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts b/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts deleted file mode 100644 index 99b443cffc..0000000000 --- a/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useEffect } from 'react'; -import { type Config } from '@google/gemini-cli-core'; -import { type Settings } from '../../config/settings.js'; -import { getAllSessionFiles } from '../../utils/sessionUtils.js'; -import { identifySessionsToDelete } from '../../utils/sessionCleanup.js'; -import path from 'node:path'; - -export function useSessionRetentionCheck( - config: Config, - settings: Settings, - onAutoEnable?: () => void, -) { - const [shouldShowWarning, setShouldShowWarning] = useState(false); - const [sessionsToDeleteCount, setSessionsToDeleteCount] = useState(0); - const [checkComplete, setCheckComplete] = useState(false); - - useEffect(() => { - // If warning already acknowledged or retention already enabled, skip check - if ( - settings.general?.sessionRetention?.warningAcknowledged || - (settings.general?.sessionRetention?.enabled && - settings.general?.sessionRetention?.maxAge !== undefined) - ) { - setShouldShowWarning(false); - setCheckComplete(true); - return; - } - - const checkSessions = async () => { - try { - const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); - const allFiles = await getAllSessionFiles( - chatsDir, - config.getSessionId(), - ); - - // Calculate how many sessions would be deleted if we applied a 30-day retention - const sessionsToDelete = await identifySessionsToDelete(allFiles, { - enabled: true, - maxAge: '30d', - }); - - if (sessionsToDelete.length > 0) { - setSessionsToDeleteCount(sessionsToDelete.length); - setShouldShowWarning(true); - } else { - setShouldShowWarning(false); - // If no sessions to delete, safe to auto-enable retention - onAutoEnable?.(); - } - } catch { - // If we can't check sessions, default to not showing the warning to be safe - setShouldShowWarning(false); - } finally { - setCheckComplete(true); - } - }; - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - checkSessions(); - }, [config, settings.general?.sessionRetention, onAutoEnable]); - - return { shouldShowWarning, checkComplete, sessionsToDeleteCount }; -} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index b93be1f0e7..c2919b5a7d 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -158,14 +158,15 @@ "enabled": { "title": "Enable Session Cleanup", "description": "Enable automatic session cleanup", - "markdownDescription": "Enable automatic session cleanup\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", - "default": false, + "markdownDescription": "Enable automatic session cleanup\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `true`", + "default": true, "type": "boolean" }, "maxAge": { "title": "Keep chat history", "description": "Automatically delete chats older than this time period (e.g., \"30d\", \"7d\", \"24h\", \"1w\")", - "markdownDescription": "Automatically delete chats older than this time period (e.g., \"30d\", \"7d\", \"24h\", \"1w\")\n\n- Category: `General`\n- Requires restart: `no`", + "markdownDescription": "Automatically delete chats older than this time period (e.g., \"30d\", \"7d\", \"24h\", \"1w\")\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `30d`", + "default": "30d", "type": "string" }, "maxCount": { @@ -180,13 +181,6 @@ "markdownDescription": "Minimum retention period (safety limit, defaults to \"1d\")\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `1d`", "default": "1d", "type": "string" - }, - "warningAcknowledged": { - "title": "Warning Acknowledged", - "description": "INTERNAL: Whether the user has acknowledged the session retention warning", - "markdownDescription": "INTERNAL: Whether the user has acknowledged the session retention warning\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", - "default": false, - "type": "boolean" } }, "additionalProperties": false