From 390edb5e0a9fa61b0bfccb48e543a18ce028c873 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Tue, 5 Aug 2025 18:10:29 -0700 Subject: [PATCH 001/107] Add tests for useAtCompletion reset logic (#5639) --- .../cli/src/ui/hooks/useAtCompletion.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index bf2453f5..58602d99 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -283,6 +283,61 @@ describe('useAtCompletion', () => { }); }); + describe('State Management', () => { + it('should reset the state when disabled after being in a READY state', async () => { + const structure: FileSystemStructure = { 'a.txt': '' }; + testRootDir = await createTmpDir(structure); + + const { result, rerender } = renderHook( + ({ enabled }) => + useTestHarnessForAtCompletion(enabled, 'a', mockConfig, testRootDir), + { initialProps: { enabled: true } }, + ); + + // Wait for the hook to be ready and have suggestions + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'a.txt', + ]); + }); + + // Now, disable the hook + rerender({ enabled: false }); + + // The suggestions should be cleared immediately because of the RESET action + expect(result.current.suggestions).toEqual([]); + }); + + it('should reset the state when disabled after being in an ERROR state', async () => { + testRootDir = await createTmpDir({}); + + // Force an error during initialization + vi.spyOn(FileSearch.prototype, 'initialize').mockRejectedValueOnce( + new Error('Initialization failed'), + ); + + const { result, rerender } = renderHook( + ({ enabled }) => + useTestHarnessForAtCompletion(enabled, '', mockConfig, testRootDir), + { initialProps: { enabled: true } }, + ); + + // Wait for the hook to enter the error state + await waitFor(() => { + expect(result.current.isLoadingSuggestions).toBe(false); + }); + expect(result.current.suggestions).toEqual([]); // No suggestions on error + + // Now, disable the hook + rerender({ enabled: false }); + + // The state should still be reset (though visually it's the same) + // We can't directly inspect the internal state, but we can ensure it doesn't crash + // and the suggestions remain empty. + expect(result.current.suggestions).toEqual([]); + }); + }); + describe('Filtering and Configuration', () => { it('should respect .gitignore files', async () => { const gitignoreContent = ['dist/', '*.log'].join('\n'); From 9db5aab4987ad64317a2f6724880265f8124250d Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 6 Aug 2025 01:13:22 +0000 Subject: [PATCH 002/107] Update a couple more witty phrases (#5641) --- packages/cli/src/ui/hooks/usePhraseCycler.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index e87b4a03..73c8ccf0 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -43,7 +43,6 @@ export const WITTY_LOADING_PHRASES = [ 'Garbage collecting... be right back...', 'Assembling the interwebs...', 'Converting coffee into code...', - 'Pushing to production (and hoping for the best)...', 'Updating the syntax for reality...', 'Rewiring the synapses...', 'Looking for a misplaced semicolon...', @@ -99,7 +98,7 @@ export const WITTY_LOADING_PHRASES = [ 'Why did the computer go to therapy? It had too many bytes...', "Why don't programmers like nature? It has too many bugs...", 'Why do programmers prefer dark mode? Because light attracts bugs...', - 'Why did the developer go broke? Because he used up all his cache...', + 'Why did the developer go broke? Because they used up all their cache...', "What can you do with a broken pencil? Nothing, it's pointless...", 'Applying percussive maintenance...', 'Searching for the correct USB orientation...', From 7e5a5e2da79783554dc4e3f00787317db29a589a Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Tue, 5 Aug 2025 18:48:00 -0700 Subject: [PATCH 003/107] Detect and warn about cyclic tool refs when schema depth errors are encountered (#5609) Co-authored-by: Jacob Richman --- packages/core/src/core/geminiChat.ts | 39 ++++++++ packages/core/src/tools/tools.test.ts | 125 ++++++++++++++++++++++++++ packages/core/src/tools/tools.ts | 85 ++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 packages/core/src/tools/tools.test.ts diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index bd81400f..c0e41b5e 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -32,6 +32,8 @@ import { ApiResponseEvent, } from '../telemetry/types.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; +import { hasCycleInSchema } from '../tools/tools.js'; +import { isStructuredError } from '../utils/quotaErrorDetection.js'; /** * Returns true if the response is valid, false otherwise. @@ -299,6 +301,10 @@ export class GeminiChat { response = await retryWithBackoff(apiCall, { shouldRetry: (error: Error) => { + // Check for likely cyclic schema errors, don't retry those. + if (error.message.includes('maximum schema depth exceeded')) + return false; + // Check error messages for status codes, or specific error names if known if (error && error.message) { if (error.message.includes('429')) return true; if (error.message.match(/5\d{2}/)) return true; @@ -345,6 +351,7 @@ export class GeminiChat { } catch (error) { const durationMs = Date.now() - startTime; this._logApiError(durationMs, error, prompt_id); + await this.maybeIncludeSchemaDepthContext(error); this.sendPromise = Promise.resolve(); throw error; } @@ -413,6 +420,9 @@ export class GeminiChat { // If errors occur mid-stream, this setup won't resume the stream; it will restart it. const streamResponse = await retryWithBackoff(apiCall, { shouldRetry: (error: Error) => { + // Check for likely cyclic schema errors, don't retry those. + if (error.message.includes('maximum schema depth exceeded')) + return false; // Check error messages for status codes, or specific error names if known if (error && error.message) { if (error.message.includes('429')) return true; @@ -443,6 +453,7 @@ export class GeminiChat { const durationMs = Date.now() - startTime; this._logApiError(durationMs, error, prompt_id); this.sendPromise = Promise.resolve(); + await this.maybeIncludeSchemaDepthContext(error); throw error; } } @@ -674,4 +685,32 @@ export class GeminiChat { content.parts[0].thought === true ); } + + private async maybeIncludeSchemaDepthContext(error: unknown): Promise { + // Check for potentially problematic cyclic tools with cyclic schemas + // and include a recommendation to remove potentially problematic tools. + if ( + isStructuredError(error) && + error.message.includes('maximum schema depth exceeded') + ) { + const tools = (await this.config.getToolRegistry()).getAllTools(); + const cyclicSchemaTools: string[] = []; + for (const tool of tools) { + if ( + (tool.schema.parametersJsonSchema && + hasCycleInSchema(tool.schema.parametersJsonSchema)) || + (tool.schema.parameters && hasCycleInSchema(tool.schema.parameters)) + ) { + cyclicSchemaTools.push(tool.displayName); + } + } + if (cyclicSchemaTools.length > 0) { + const extraDetails = + `\n\nThis error was probably caused by cyclic schema references in one of the following tools, try disabling them:\n\n - ` + + cyclicSchemaTools.join(`\n - `) + + `\n`; + error.message += extraDetails; + } + } + } } diff --git a/packages/core/src/tools/tools.test.ts b/packages/core/src/tools/tools.test.ts new file mode 100644 index 00000000..9942d3a9 --- /dev/null +++ b/packages/core/src/tools/tools.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { hasCycleInSchema } from './tools.js'; // Added getStringifiedResultForDisplay + +describe('hasCycleInSchema', () => { + it('should detect a simple direct cycle', () => { + const schema = { + properties: { + data: { + $ref: '#/properties/data', + }, + }, + }; + expect(hasCycleInSchema(schema)).toBe(true); + }); + + it('should detect a cycle from object properties referencing parent properties', () => { + const schema = { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + child: { $ref: '#/properties/data' }, + }, + }, + }, + }; + expect(hasCycleInSchema(schema)).toBe(true); + }); + + it('should detect a cycle from array items referencing parent properties', () => { + const schema = { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + child: { $ref: '#/properties/data/items' }, + }, + }, + }, + }, + }; + expect(hasCycleInSchema(schema)).toBe(true); + }); + + it('should detect a cycle between sibling properties', () => { + const schema = { + type: 'object', + properties: { + a: { + type: 'object', + properties: { + child: { $ref: '#/properties/b' }, + }, + }, + b: { + type: 'object', + properties: { + child: { $ref: '#/properties/a' }, + }, + }, + }, + }; + expect(hasCycleInSchema(schema)).toBe(true); + }); + + it('should not detect a cycle in a valid schema', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + address: { $ref: '#/definitions/address' }, + }, + definitions: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + }, + }, + }, + }; + expect(hasCycleInSchema(schema)).toBe(false); + }); + + it('should handle non-cyclic sibling refs', () => { + const schema = { + properties: { + a: { $ref: '#/definitions/stringDef' }, + b: { $ref: '#/definitions/stringDef' }, + }, + definitions: { + stringDef: { type: 'string' }, + }, + }; + expect(hasCycleInSchema(schema)).toBe(false); + }); + + it('should handle nested but not cyclic refs', () => { + const schema = { + properties: { + a: { $ref: '#/definitions/defA' }, + }, + definitions: { + defA: { properties: { b: { $ref: '#/definitions/defB' } } }, + defB: { type: 'string' }, + }, + }; + expect(hasCycleInSchema(schema)).toBe(false); + }); + + it('should return false for an empty schema', () => { + expect(hasCycleInSchema({})).toBe(false); + }); +}); diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 0e3ffabf..5d9d9253 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -228,6 +228,91 @@ export interface ToolResult { }; } +/** + * Detects cycles in a JSON schemas due to `$ref`s. + * @param schema The root of the JSON schema. + * @returns `true` if a cycle is detected, `false` otherwise. + */ +export function hasCycleInSchema(schema: object): boolean { + function resolveRef(ref: string): object | null { + if (!ref.startsWith('#/')) { + return null; + } + const path = ref.substring(2).split('/'); + let current: unknown = schema; + for (const segment of path) { + if ( + typeof current !== 'object' || + current === null || + !Object.prototype.hasOwnProperty.call(current, segment) + ) { + return null; + } + current = (current as Record)[segment]; + } + return current as object; + } + + function traverse( + node: unknown, + visitedRefs: Set, + pathRefs: Set, + ): boolean { + if (typeof node !== 'object' || node === null) { + return false; + } + + if (Array.isArray(node)) { + for (const item of node) { + if (traverse(item, visitedRefs, pathRefs)) { + return true; + } + } + return false; + } + + if ('$ref' in node && typeof node.$ref === 'string') { + const ref = node.$ref; + if (ref === '#/' || pathRefs.has(ref)) { + // A ref to just '#/' is always a cycle. + return true; // Cycle detected! + } + if (visitedRefs.has(ref)) { + return false; // Bail early, we have checked this ref before. + } + + const resolvedNode = resolveRef(ref); + if (resolvedNode) { + // Add it to both visited and the current path + visitedRefs.add(ref); + pathRefs.add(ref); + const hasCycle = traverse(resolvedNode, visitedRefs, pathRefs); + pathRefs.delete(ref); // Backtrack, leaving it in visited + return hasCycle; + } + } + + // Crawl all the properties of node + for (const key in node) { + if (Object.prototype.hasOwnProperty.call(node, key)) { + if ( + traverse( + (node as Record)[key], + visitedRefs, + pathRefs, + ) + ) { + return true; + } + } + } + + return false; + } + + return traverse(schema, new Set(), new Set()); +} + export type ToolResultDisplay = string | FileDiff; export interface FileDiff { From b87b436ebce46f13701fdec0fa1e6974d604a958 Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Tue, 5 Aug 2025 19:24:40 -0700 Subject: [PATCH 004/107] refactor: use `google-github-actions/run-gemini-cli` action (#5643) --- .../gemini-automated-issue-triage.yml | 127 +++++++++++++----- .../gemini-scheduled-issue-triage.yml | 121 +++++++++++------ 2 files changed, 168 insertions(+), 80 deletions(-) diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index 63aa0742..495ccee1 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -1,56 +1,95 @@ -name: Gemini Automated Issue Triage +name: '🏷️ Gemini Automated Issue Triage' on: issues: - types: [opened, reopened] + types: + - 'opened' + - 'reopened' + issue_comment: + types: + - 'created' + workflow_dispatch: + inputs: + issue_number: + description: 'issue number to triage' + required: true + type: 'number' + +concurrency: + group: '${{ github.workflow }}-${{ github.event.issue.number }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + statuses: 'write' jobs: triage-issue: + if: > + github.repository == 'google-gemini/gemini-cli' && + (github.event_name == 'issues' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@gemini-cli /triage') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR'))) timeout-minutes: 5 - if: ${{ github.repository == 'google-gemini/gemini-cli' }} - permissions: - issues: write - contents: read - id-token: write - concurrency: - group: ${{ github.workflow }}-${{ github.event.issue.number }} - cancel-in-progress: true - runs-on: ubuntu-latest - steps: - - name: Generate GitHub App Token - id: generate_token - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.PRIVATE_KEY }} + runs-on: 'ubuntu-latest' - - name: Run Gemini Issue Triage - uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff - env: - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - ISSUE_TITLE: ${{ github.event.issue.title }} - ISSUE_BODY: ${{ github.event.issue.body }} + steps: + - name: 'Checkout repository' + uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' + + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e' with: - version: 0.1.8-rc.0 - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }} - OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }} - settings_json: | + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Run Gemini Issue Triage' + uses: 'google-github-actions/run-gemini-cli@68d5a6d2e31ff01029205c58c6bf81cb3d72910b' + id: 'gemini_issue_triage' + env: + GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' + ISSUE_TITLE: '${{ github.event.issue.title }}' + ISSUE_BODY: '${{ github.event.issue.body }}' + ISSUE_NUMBER: '${{ github.event.issue.number }}' + REPOSITORY: '${{ github.repository }}' + with: + gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' + gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' + use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' + settings: |- { + "maxSessionTurns": 25, "coreTools": [ "run_shell_command(gh label list)", - "run_shell_command(gh issue edit)", - "run_shell_command(gh issue list)" + "run_shell_command(gh issue edit)" ], "telemetry": { "enabled": true, "target": "gcp" - }, - "sandbox": false + } } - prompt: | + prompt: |- + ## Role + You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue. - Steps: + + ## Steps + 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. 2. Review the issue title, body and any comments provided in the environment variables. 3. Ignore any existing priorities or tags on the issue. Just report your findings. @@ -59,7 +98,9 @@ jobs: 7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label 8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label 9. Use Area definitions mentioned below to help you narrow down issues - Guidelines: + + ## Guidelines + - Only use labels that already exist in the repository. - Do not add comments or modify the issue content. - Triage only the current issue. @@ -143,3 +184,17 @@ jobs: - could also pertain to latency, - other general software performance like, memory usage, CPU consumption, and algorithmic efficiency. - Switching models from one to the other unexpectedly. + + - name: 'Post Issue Triage Failure Comment' + if: |- + ${{ failure() && steps.gemini_issue_triage.outcome == 'failure' }} + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: |- + github.rest.issues.createComment({ + owner: '${{ github.repository }}'.split('/')[0], + repo: '${{ github.repository }}'.split('/')[1], + issue_number: '${{ github.event.issue.number }}', + body: 'There is a problem with the Gemini CLI issue triaging. Please check the [action logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.' + }) diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 7e083c84..f8ce8dab 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -1,76 +1,107 @@ -name: Gemini Scheduled Issue Triage +name: '📋 Gemini Scheduled Issue Triage' on: schedule: - cron: '0 * * * *' # Runs every hour - workflow_dispatch: {} + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + statuses: 'write' jobs: triage-issues: timeout-minutes: 10 if: ${{ github.repository == 'google-gemini/gemini-cli' }} - runs-on: ubuntu-latest - permissions: - contents: read - id-token: write - issues: write + runs-on: 'ubuntu-latest' + steps: - - name: Generate GitHub App Token - id: generate_token - uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2 + - name: 'Checkout repository' + uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 + + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e' # ratchet:actions/create-github-app-token@v2 with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.PRIVATE_KEY }} + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' - - name: Find untriaged issues - id: find_issues + - name: 'Find untriaged issues' + id: 'find_issues' env: - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - run: | - echo "🔍 Finding issues without labels..." - NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body) + GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' + GITHUB_REPOSITORY: '${{ github.repository }}' + GITHUB_OUTPUT: '${{ github.output }}' + run: |- + set -euo pipefail - echo "🏷️ Finding issues that need triage..." - NEED_TRIAGE_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue label:\"status/need-triage\"" --json number,title,body) + echo '🔍 Finding issues without labels...' + NO_LABEL_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue no:label' --json number,title,body)" - echo "🔄 Merging and deduplicating issues..." - ISSUES=$(echo "$NO_LABEL_ISSUES" "$NEED_TRIAGE_ISSUES" | jq -c -s 'add | unique_by(.number)') + echo '🏷️ Finding issues that need triage...' + NEED_TRIAGE_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue label:"status/needs-triage"' --json number,title,body)" - echo "📝 Setting output for GitHub Actions..." - echo "issues_to_triage=$ISSUES" >> "$GITHUB_OUTPUT" + echo '🔄 Merging and deduplicating issues...' + ISSUES="$(echo "${NO_LABEL_ISSUES}" "${NEED_TRIAGE_ISSUES}" | jq -c -s 'add | unique_by(.number)')" - echo "✅ Found $(echo "$ISSUES" | jq 'length') issues to triage! 🎯" + echo '📝 Setting output for GitHub Actions...' + echo "issues_to_triage=${ISSUES}" >> "${GITHUB_OUTPUT}" - - name: Run Gemini Issue Triage - if: steps.find_issues.outputs.issues_to_triage != '[]' - uses: google-gemini/gemini-cli-action@df3f890f003d28c60a2a09d2c29e0126e4d1e2ff + ISSUE_COUNT="$(echo "${ISSUES}" | jq 'length')" + echo "✅ Found ${ISSUE_COUNT} issues to triage! 🎯" + + - name: 'Run Gemini Issue Triage' + if: |- + ${{ steps.find_issues.outputs.issues_to_triage != '[]' }} + uses: 'google-github-actions/run-gemini-cli@68d5a6d2e31ff01029205c58c6bf81cb3d72910b' + id: 'gemini_issue_triage' env: - GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} - ISSUES_TO_TRIAGE: ${{ steps.find_issues.outputs.issues_to_triage }} - REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' + ISSUES_TO_TRIAGE: '${{ steps.find_issues.outputs.issues_to_triage }}' + REPOSITORY: '${{ github.repository }}' with: - version: 0.1.8-rc.0 - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - OTLP_GCP_WIF_PROVIDER: ${{ secrets.OTLP_GCP_WIF_PROVIDER }} - OTLP_GOOGLE_CLOUD_PROJECT: ${{ secrets.OTLP_GOOGLE_CLOUD_PROJECT }} - settings_json: | + gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' + gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' + use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' + settings: |- { + "maxSessionTurns": 25, "coreTools": [ "run_shell_command(echo)", "run_shell_command(gh label list)", "run_shell_command(gh issue edit)", - "run_shell_command(gh issue list)", "run_shell_command(gh issue view)" ], "telemetry": { "enabled": true, "target": "gcp" - }, - "sandbox": false + } } - prompt: | - You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. - Steps: + prompt: |- + ## Role + + You are an issue triage assistant. Analyze issues and apply + appropriate labels. Use the available tools to gather information; + do not ask for information to be provided. + + ## Steps + 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. 2. Check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues) 3. Review the issue title, body and any comments provided in the environment variables. @@ -95,8 +126,10 @@ jobs: - After applying appropriate labels to an issue, remove the "status/need-triage" label if present: `gh issue edit ISSUE_NUMBER --repo ${{ github.repository }} --remove-label "status/need-triage"` - Execute one `gh issue edit` command per issue, wait for success before proceeding to the next Process each issue sequentially and confirm each labeling operation before moving to the next issue. - Guidelines: - - Only use labels that already exist in the repository. + + ## Guidelines + + - Only use labels that already exist in the repository. - Do not add comments or modify the issue content. - Do not remove labels titled help wanted or good first issue. - Triage only the current issue. From be3aabaea6741091e7b2bd4a131f51148c7c2886 Mon Sep 17 00:00:00 2001 From: Lee James <40045512+leehagoodjames@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:59:25 -0400 Subject: [PATCH 005/107] docs(setup-github): Inform user of the next steps after running slash command (#5644) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/src/ui/commands/setupGithubCommand.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index e330cfab..445c0e76 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -44,7 +44,8 @@ export const setupGithubCommand: SlashCommand = { const fileName = path.basename(workflow); return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`; }), - 'echo "Workflows downloaded successfully."', + 'echo "Workflows downloaded successfully. Follow steps in https://github.com/google-github-actions/run-gemini-cli/blob/v0/README.md#quick-start (skipping the /setup-github step) to complete setup."', + 'open https://github.com/google-github-actions/run-gemini-cli/blob/v0/README.md#quick-start', ].join(' && '); return { type: 'tool', From 7fa2d7be176369f4a0847741a7df88d20f2e2152 Mon Sep 17 00:00:00 2001 From: Lee James <40045512+leehagoodjames@users.noreply.github.com> Date: Tue, 5 Aug 2025 23:21:36 -0400 Subject: [PATCH 006/107] doc(lint): fix docs on how to run linter in "fix" mode (#5647) --- docs/integration-tests.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/integration-tests.md b/docs/integration-tests.md index 53ddd155..7a4c8489 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -109,10 +109,10 @@ To check for linting errors, run the following command: npm run lint ``` -You can include the `--fix` flag in the command to automatically fix any fixable linting errors: +You can include the `:fix` flag in the command to automatically fix any fixable linting errors: ```bash -npm run lint --fix +npm run lint:fix ``` ## Directory structure From 2fcaa302da40f68b7e9a494a11c450def0a1e4d4 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Wed, 6 Aug 2025 13:01:42 +0900 Subject: [PATCH 007/107] docs: add GitHub Integration section to README (#5649) --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index ad531676..b1c8f38c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ With the Gemini CLI you can: - Query and edit large codebases in and beyond Gemini's 1M token context window. - Generate new apps from PDFs or sketches, using Gemini's multimodal capabilities. - Automate operational tasks, like querying pull requests or handling complex rebases. +- Integrate with GitHub: Use the [Gemini CLI GitHub Action](https://github.com/google-github-actions/run-gemini-cli) for automated PR reviews, issue triage, and on-demand AI assistance directly in your repositories. - Use tools and MCP servers to connect new capabilities, including [media generation with Imagen, Veo or Lyria](https://github.com/GoogleCloudPlatform/vertex-ai-creative-studio/tree/main/experiments/mcp-genmedia) - Ground your queries with the [Google Search](https://ai.google.dev/gemini-api/docs/grounding) @@ -128,6 +129,15 @@ gemini Head over to the [troubleshooting guide](docs/troubleshooting.md) if you're having issues. +## GitHub Integration + +Integrate Gemini CLI directly into your GitHub workflows with the [**Gemini CLI GitHub Action**](https://github.com/google-github-actions/run-gemini-cli). Key features include: + +- **Pull Request Reviews**: Automatically review pull requests when they're opened. +- **Issue Triage**: Automatically triage and label GitHub issues. +- **On-demand Collaboration**: Mention `@gemini-cli` in issues and pull requests for assistance and task delegation. +- **Custom Workflows**: Set up your own scheduled tasks and event-driven automations. + ## Popular tasks ### Explore a new codebase From a0990380b5f96cc826bd8be50b12e8d034d0eccd Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:26:27 -0700 Subject: [PATCH 008/107] fix:missing coreTool in new workflow setup (#5656) --- .github/workflows/gemini-automated-issue-triage.yml | 3 ++- .github/workflows/gemini-scheduled-issue-triage.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index 495ccee1..d29484f7 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -76,7 +76,8 @@ jobs: "maxSessionTurns": 25, "coreTools": [ "run_shell_command(gh label list)", - "run_shell_command(gh issue edit)" + "run_shell_command(gh issue edit)", + "run_shell_command(gh issue list)" ], "telemetry": { "enabled": true, diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index f8ce8dab..4e6f8a00 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -86,7 +86,8 @@ jobs: "run_shell_command(echo)", "run_shell_command(gh label list)", "run_shell_command(gh issue edit)", - "run_shell_command(gh issue view)" + "run_shell_command(gh issue view)", + "run_shell_command(gh issue list)" ], "telemetry": { "enabled": true, From 8b1d5a2e3c84e488d90184e7da856cf1130ea5ef Mon Sep 17 00:00:00 2001 From: Yash Velagapudi <46730550+yashv6655@users.noreply.github.com> Date: Tue, 5 Aug 2025 23:15:53 -0700 Subject: [PATCH 009/107] fix(core): Treat .mts files as TypeScript modules instead of video files (#5492) Co-authored-by: Jacob Richman --- packages/core/src/utils/fileUtils.test.ts | 6 +++++- packages/core/src/utils/fileUtils.ts | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index bcdf3fe7..fb6b6820 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -196,9 +196,13 @@ describe('fileUtils', () => { vi.restoreAllMocks(); // Restore spies on actualNodeFs }); - it('should detect typescript type by extension (ts)', async () => { + it('should detect typescript type by extension (ts, mts, cts, tsx)', async () => { expect(await detectFileType('file.ts')).toBe('text'); expect(await detectFileType('file.test.ts')).toBe('text'); + expect(await detectFileType('file.mts')).toBe('text'); + expect(await detectFileType('vite.config.mts')).toBe('text'); + expect(await detectFileType('file.cts')).toBe('text'); + expect(await detectFileType('component.tsx')).toBe('text'); }); it('should detect image type by extension (png)', async () => { diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 96f4b36c..a153d205 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -122,9 +122,10 @@ export async function detectFileType( ): Promise<'text' | 'image' | 'pdf' | 'audio' | 'video' | 'binary' | 'svg'> { const ext = path.extname(filePath).toLowerCase(); - // The mimetype for "ts" is MPEG transport stream (a video format) but we want - // to assume these are typescript files instead. - if (ext === '.ts') { + // The mimetype for various TypeScript extensions (ts, mts, cts, tsx) can be + // MPEG transport stream (a video format), but we want to assume these are + // TypeScript files instead. + if (['.ts', '.mts', '.cts'].includes(ext)) { return 'text'; } From aab850668c99e1c39a55036069d9f4b06ca458f4 Mon Sep 17 00:00:00 2001 From: Bryant Chandler Date: Tue, 5 Aug 2025 23:33:27 -0700 Subject: [PATCH 010/107] feat(file-search): Add support for non-recursive file search (#5648) Co-authored-by: Jacob Richman --- .../cli/src/ui/hooks/useAtCompletion.test.ts | 38 +++++ packages/cli/src/ui/hooks/useAtCompletion.ts | 3 + .../src/utils/filesearch/crawlCache.test.ts | 11 ++ .../core/src/utils/filesearch/crawlCache.ts | 4 + .../src/utils/filesearch/fileSearch.test.ts | 145 ++++++++++++++++++ .../core/src/utils/filesearch/fileSearch.ts | 7 + 6 files changed, 208 insertions(+) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 58602d99..43289992 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -50,6 +50,7 @@ describe('useAtCompletion', () => { respectGitIgnore: true, respectGeminiIgnore: true, })), + getEnableRecursiveFileSearch: () => true, } as unknown as Config; vi.clearAllMocks(); }); @@ -431,5 +432,42 @@ describe('useAtCompletion', () => { await cleanupTmpDir(rootDir1); await cleanupTmpDir(rootDir2); }); + + it('should perform a non-recursive search when enableRecursiveFileSearch is false', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + src: { + 'index.js': '', + }, + }; + testRootDir = await createTmpDir(structure); + + const nonRecursiveConfig = { + getEnableRecursiveFileSearch: () => false, + getFileFilteringOptions: vi.fn(() => ({ + respectGitIgnore: true, + respectGeminiIgnore: true, + })), + } as unknown as Config; + + const { result } = renderHook(() => + useTestHarnessForAtCompletion( + true, + '', + nonRecursiveConfig, + testRootDir, + ), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + // Should only contain top-level items + expect(result.current.suggestions.map((s) => s.value)).toEqual([ + 'src/', + 'file.txt', + ]); + }); }); }); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index e63a707f..82439c14 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -165,6 +165,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void { config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true, cache: true, cacheTtl: 30, // 30 seconds + maxDepth: !(config?.getEnableRecursiveFileSearch() ?? true) + ? 0 + : undefined, }); await searcher.initialize(); fileSearch.current = searcher; diff --git a/packages/core/src/utils/filesearch/crawlCache.test.ts b/packages/core/src/utils/filesearch/crawlCache.test.ts index 2feab61a..c8ca0df2 100644 --- a/packages/core/src/utils/filesearch/crawlCache.test.ts +++ b/packages/core/src/utils/filesearch/crawlCache.test.ts @@ -26,6 +26,17 @@ describe('CrawlCache', () => { const key2 = getCacheKey('/foo', 'baz'); expect(key1).not.toBe(key2); }); + + it('should generate a different hash for different maxDepth values', () => { + const key1 = getCacheKey('/foo', 'bar', 1); + const key2 = getCacheKey('/foo', 'bar', 2); + const key3 = getCacheKey('/foo', 'bar', undefined); + const key4 = getCacheKey('/foo', 'bar'); + expect(key1).not.toBe(key2); + expect(key1).not.toBe(key3); + expect(key2).not.toBe(key3); + expect(key3).toBe(key4); + }); }); describe('in-memory cache operations', () => { diff --git a/packages/core/src/utils/filesearch/crawlCache.ts b/packages/core/src/utils/filesearch/crawlCache.ts index 3cc948c6..b905c9df 100644 --- a/packages/core/src/utils/filesearch/crawlCache.ts +++ b/packages/core/src/utils/filesearch/crawlCache.ts @@ -17,10 +17,14 @@ const cacheTimers = new Map(); export const getCacheKey = ( directory: string, ignoreContent: string, + maxDepth?: number, ): string => { const hash = crypto.createHash('sha256'); hash.update(directory); hash.update(ignoreContent); + if (maxDepth !== undefined) { + hash.update(String(maxDepth)); + } return hash.digest('hex'); }; diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts index b804d623..a7f59f91 100644 --- a/packages/core/src/utils/filesearch/fileSearch.test.ts +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -446,6 +446,46 @@ describe('FileSearch', () => { expect(crawlSpy).toHaveBeenCalledTimes(1); }); + + it('should miss the cache when maxDepth changes', async () => { + tmpDir = await createTmpDir({ 'file1.js': '' }); + const getOptions = (maxDepth?: number) => ({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: true, + cacheTtl: 10000, + maxDepth, + }); + + // 1. First search with maxDepth: 1, should trigger a crawl. + const fs1 = new FileSearch(getOptions(1)); + const crawlSpy1 = vi.spyOn( + fs1 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs1.initialize(); + expect(crawlSpy1).toHaveBeenCalledTimes(1); + + // 2. Second search with maxDepth: 2, should be a cache miss and trigger a crawl. + const fs2 = new FileSearch(getOptions(2)); + const crawlSpy2 = vi.spyOn( + fs2 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs2.initialize(); + expect(crawlSpy2).toHaveBeenCalledTimes(1); + + // 3. Third search with maxDepth: 1 again, should be a cache hit. + const fs3 = new FileSearch(getOptions(1)); + const crawlSpy3 = vi.spyOn( + fs3 as FileSearchWithPrivateMethods, + 'performCrawl', + ); + await fs3.initialize(); + expect(crawlSpy3).not.toHaveBeenCalled(); + }); }); it('should handle empty or commented-only ignore files', async () => { @@ -639,4 +679,109 @@ describe('FileSearch', () => { // 3. Assert that the maxResults limit was respected, even with a cache hit. expect(limitedResults).toEqual(['file1.js', 'file2.js']); }); + + describe('with maxDepth', () => { + beforeEach(async () => { + tmpDir = await createTmpDir({ + 'file-root.txt': '', + level1: { + 'file-level1.txt': '', + level2: { + 'file-level2.txt': '', + level3: { + 'file-level3.txt': '', + }, + }, + }, + }); + }); + + it('should only search top-level files when maxDepth is 0', async () => { + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + maxDepth: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual(['level1/', 'file-root.txt']); + }); + + it('should search one level deep when maxDepth is 1', async () => { + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + maxDepth: 1, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'level1/', + 'level1/level2/', + 'file-root.txt', + 'level1/file-level1.txt', + ]); + }); + + it('should search two levels deep when maxDepth is 2', async () => { + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + maxDepth: 2, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'level1/', + 'level1/level2/', + 'level1/level2/level3/', + 'file-root.txt', + 'level1/file-level1.txt', + 'level1/level2/file-level2.txt', + ]); + }); + + it('should perform a full recursive search when maxDepth is undefined', async () => { + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + maxDepth: undefined, // Explicitly undefined + }); + + await fileSearch.initialize(); + const results = await fileSearch.search(''); + + expect(results).toEqual([ + 'level1/', + 'level1/level2/', + 'level1/level2/level3/', + 'file-root.txt', + 'level1/file-level1.txt', + 'level1/level2/file-level2.txt', + 'level1/level2/level3/file-level3.txt', + ]); + }); + }); }); diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 5915821a..db14bc65 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -19,6 +19,7 @@ export type FileSearchOptions = { useGeminiignore: boolean; cache: boolean; cacheTtl: number; + maxDepth?: number; }; export class AbortError extends Error { @@ -215,6 +216,7 @@ export class FileSearch { const cacheKey = cache.getCacheKey( this.absoluteDir, this.ignore.getFingerprint(), + this.options.maxDepth, ); const cachedResults = cache.read(cacheKey); @@ -230,6 +232,7 @@ export class FileSearch { const cacheKey = cache.getCacheKey( this.absoluteDir, this.ignore.getFingerprint(), + this.options.maxDepth, ); cache.write(cacheKey, this.allFiles, this.options.cacheTtl * 1000); } @@ -257,6 +260,10 @@ export class FileSearch { return dirFilter(`${relativePath}/`); }); + if (this.options.maxDepth !== undefined) { + api.withMaxDepth(this.options.maxDepth); + } + return api.crawl(this.absoluteDir).withPromise(); } From b38f377c9a2672e88dd15119b38d908c0f00b54a Mon Sep 17 00:00:00 2001 From: Lee James <40045512+leehagoodjames@users.noreply.github.com> Date: Wed, 6 Aug 2025 09:06:37 -0400 Subject: [PATCH 011/107] feat: Enable /setup-github to always run, and error appropriately (#5653) Co-authored-by: Jacob Richman --- .../cli/src/services/BuiltinCommandLoader.ts | 3 +-- .../ui/commands/setupGithubCommand.test.ts | 4 +++- .../cli/src/ui/commands/setupGithubCommand.ts | 19 ++++++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 46ecb37c..c09f7c61 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -32,7 +32,6 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; -import { isGitHubRepository } from '../utils/gitUtils.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -74,7 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader { themeCommand, toolsCommand, vimCommand, - ...(isGitHubRepository() ? [setupGithubCommand] : []), + setupGithubCommand, ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 891c84e7..ae6378c7 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -61,6 +61,8 @@ describe('setupGithubCommand', () => { vi.mocked(child_process.execSync).mockReturnValue(''); expect(() => { setupGithubCommand.action?.({} as CommandContext, ''); - }).toThrow('Unable to determine the Git root directory.'); + }).toThrow( + 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', + ); }); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 445c0e76..047e11eb 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -19,12 +19,21 @@ export const setupGithubCommand: SlashCommand = { description: 'Set up GitHub Actions', kind: CommandKind.BUILT_IN, action: (): SlashCommandActionReturn => { - const gitRootRepo = execSync('git rev-parse --show-toplevel', { - encoding: 'utf-8', - }).trim(); - if (!isGitHubRepository()) { - throw new Error('Unable to determine the Git root directory.'); + throw new Error( + 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', + ); + } + + let gitRootRepo: string; + try { + gitRootRepo = execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8', + }).trim(); + } catch { + throw new Error( + 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', + ); } const version = 'v0'; From ca4c745e3b620e3ac4eca24b610cc7b936c0a50d Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Wed, 6 Aug 2025 11:52:29 -0400 Subject: [PATCH 012/107] feat(mcp): add `gemini mcp` commands for `add`, `remove` and `list` (#5481) --- docs/tools/mcp-server.md | 111 +++++++ packages/cli/src/commands/mcp.test.ts | 55 ++++ packages/cli/src/commands/mcp.ts | 27 ++ packages/cli/src/commands/mcp/add.test.ts | 88 +++++ packages/cli/src/commands/mcp/add.ts | 211 ++++++++++++ packages/cli/src/commands/mcp/list.test.ts | 154 +++++++++ packages/cli/src/commands/mcp/list.ts | 139 ++++++++ packages/cli/src/commands/mcp/remove.test.ts | 69 ++++ packages/cli/src/commands/mcp/remove.ts | 60 ++++ packages/cli/src/config/config.ts | 323 ++++++++++--------- 10 files changed, 1082 insertions(+), 155 deletions(-) create mode 100644 packages/cli/src/commands/mcp.test.ts create mode 100644 packages/cli/src/commands/mcp.ts create mode 100644 packages/cli/src/commands/mcp/add.test.ts create mode 100644 packages/cli/src/commands/mcp/add.ts create mode 100644 packages/cli/src/commands/mcp/list.test.ts create mode 100644 packages/cli/src/commands/mcp/list.ts create mode 100644 packages/cli/src/commands/mcp/remove.test.ts create mode 100644 packages/cli/src/commands/mcp/remove.ts diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 850c228e..17138bae 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -688,3 +688,114 @@ or, using positional arguments: ``` When you run this command, the Gemini CLI executes the `prompts/get` method on the MCP server with the provided arguments. The server is responsible for substituting the arguments into the prompt template and returning the final prompt text. The CLI then sends this prompt to the model for execution. This provides a convenient way to automate and share common workflows. + +## Managing MCP Servers with `gemini mcp` + +While you can always configure MCP servers by manually editing your `settings.json` file, the Gemini CLI provides a convenient set of commands to manage your server configurations programmatically. These commands streamline the process of adding, listing, and removing MCP servers without needing to directly edit JSON files. + +### Adding a Server (`gemini mcp add`) + +The `add` command configures a new MCP server in your `settings.json`. Based on the scope (`-s, --scope`), it will be added to either the user config `~/.gemini/settings.json` or the project config `.gemini/settings.json` file. + +**Command:** + +```bash +gemini mcp add [options] [args...] +``` + +- ``: A unique name for the server. +- ``: The command to execute (for `stdio`) or the URL (for `http`/`sse`). +- `[args...]`: Optional arguments for a `stdio` command. + +**Options (Flags):** + +- `-s, --scope`: Configuration scope (user or project). [default: "project"] +- `-t, --transport`: Transport type (stdio, sse, http). [default: "stdio"] +- `-e, --env`: Set environment variables (e.g. -e KEY=value). +- `-H, --header`: Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123"). +- `--timeout`: Set connection timeout in milliseconds. +- `--trust`: Trust the server (bypass all tool call confirmation prompts). +- `--description`: Set the description for the server. +- `--include-tools`: A comma-separated list of tools to include. +- `--exclude-tools`: A comma-separated list of tools to exclude. + +#### Adding an stdio server + +This is the default transport for running local servers. + +```bash +# Basic syntax +gemini mcp add [args...] + +# Example: Adding a local server +gemini mcp add my-stdio-server -e API_KEY=123 /path/to/server arg1 arg2 arg3 + +# Example: Adding a local python server +gemini mcp add python-server python server.py --port 8080 +``` + +#### Adding an HTTP server + +This transport is for servers that use the streamable HTTP transport. + +```bash +# Basic syntax +gemini mcp add --transport http + +# Example: Adding an HTTP server +gemini mcp add --transport http http-server https://api.example.com/mcp/ + +# Example: Adding an HTTP server with an authentication header +gemini mcp add --transport http secure-http https://api.example.com/mcp/ --header "Authorization: Bearer abc123" +``` + +#### Adding an SSE server + +This transport is for servers that use Server-Sent Events (SSE). + +```bash +# Basic syntax +gemini mcp add --transport sse + +# Example: Adding an SSE server +gemini mcp add --transport sse sse-server https://api.example.com/sse/ + +# Example: Adding an SSE server with an authentication header +gemini mcp add --transport sse secure-sse https://api.example.com/sse/ --header "Authorization: Bearer abc123" +``` + +### Listing Servers (`gemini mcp list`) + +To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status. + +**Command:** + +```bash +gemini mcp list +``` + +**Example Output:** + +```sh +✓ stdio-server: command: python3 server.py (stdio) - Connected +✓ http-server: https://api.example.com/mcp (http) - Connected +✗ sse-server: https://api.example.com/sse (sse) - Disconnected +``` + +### Removing a Server (`gemini mcp remove`) + +To delete a server from your configuration, use the `remove` command with the server's name. + +**Command:** + +```bash +gemini mcp remove +``` + +**Example:** + +```bash +gemini mcp remove my-server +``` + +This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`). diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts new file mode 100644 index 00000000..b4e9980c --- /dev/null +++ b/packages/cli/src/commands/mcp.test.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { mcpCommand } from './mcp.js'; +import { type Argv } from 'yargs'; +import yargs from 'yargs'; + +describe('mcp command', () => { + it('should have correct command definition', () => { + expect(mcpCommand.command).toBe('mcp'); + expect(mcpCommand.describe).toBe('Manage MCP servers'); + expect(typeof mcpCommand.builder).toBe('function'); + expect(typeof mcpCommand.handler).toBe('function'); + }); + + it('should have exactly one option (help flag)', () => { + // Test to ensure that the global 'gemini' flags are not added to the mcp command + const yargsInstance = yargs(); + const builtYargs = mcpCommand.builder(yargsInstance); + const options = builtYargs.getOptions(); + + // Should have exactly 1 option (help flag) + expect(Object.keys(options.key).length).toBe(1); + expect(options.key).toHaveProperty('help'); + }); + + it('should register add, remove, and list subcommands', () => { + const mockYargs = { + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + }; + + mcpCommand.builder(mockYargs as unknown as Argv); + + expect(mockYargs.command).toHaveBeenCalledTimes(3); + + // Verify that the specific subcommands are registered + const commandCalls = mockYargs.command.mock.calls; + const commandNames = commandCalls.map((call) => call[0].command); + + expect(commandNames).toContain('add [args...]'); + expect(commandNames).toContain('remove '); + expect(commandNames).toContain('list'); + + expect(mockYargs.demandCommand).toHaveBeenCalledWith( + 1, + 'You need at least one command before continuing.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 00000000..5e55286c --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp' command +import type { CommandModule, Argv } from 'yargs'; +import { addCommand } from './mcp/add.js'; +import { removeCommand } from './mcp/remove.js'; +import { listCommand } from './mcp/list.js'; + +export const mcpCommand: CommandModule = { + command: 'mcp', + describe: 'Manage MCP servers', + builder: (yargs: Argv) => + yargs + .command(addCommand) + .command(removeCommand) + .command(listCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => { + // yargs will automatically show help if no subcommand is provided + // thanks to demandCommand(1) in the builder. + }, +}; diff --git a/packages/cli/src/commands/mcp/add.test.ts b/packages/cli/src/commands/mcp/add.test.ts new file mode 100644 index 00000000..1d431c48 --- /dev/null +++ b/packages/cli/src/commands/mcp/add.test.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import yargs from 'yargs'; +import { addCommand } from './add.js'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as vi.Mock; + +describe('mcp add command', () => { + let parser: yargs.Argv; + let mockSetValue: vi.Mock; + + beforeEach(() => { + vi.resetAllMocks(); + const yargsInstance = yargs([]).command(addCommand); + parser = yargsInstance; + mockSetValue = vi.fn(); + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: {} }), + setValue: mockSetValue, + }); + }); + + it('should add a stdio server to project settings', async () => { + await parser.parseAsync( + 'add my-server /path/to/server arg1 arg2 -e FOO=bar', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'my-server': { + command: '/path/to/server', + args: ['arg1', 'arg2'], + env: { FOO: 'bar' }, + }, + }, + ); + }); + + it('should add an sse server to user settings', async () => { + await parser.parseAsync( + 'add --transport sse sse-server https://example.com/sse-endpoint --scope user -H "X-API-Key: your-key"', + ); + + expect(mockSetValue).toHaveBeenCalledWith(SettingScope.User, 'mcpServers', { + 'sse-server': { + url: 'https://example.com/sse-endpoint', + headers: { 'X-API-Key': 'your-key' }, + }, + }); + }); + + it('should add an http server to project settings', async () => { + await parser.parseAsync( + 'add --transport http http-server https://example.com/mcp -H "Authorization: Bearer your-token"', + ); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + { + 'http-server': { + httpUrl: 'https://example.com/mcp', + headers: { Authorization: 'Bearer your-token' }, + }, + }, + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/add.ts b/packages/cli/src/commands/mcp/add.ts new file mode 100644 index 00000000..9537e131 --- /dev/null +++ b/packages/cli/src/commands/mcp/add.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp add' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { MCPServerConfig } from '@google/gemini-cli-core'; + +async function addMcpServer( + name: string, + commandOrUrl: string, + args: Array | undefined, + options: { + scope: string; + transport: string; + env: string[] | undefined; + header: string[] | undefined; + timeout?: number; + trust?: boolean; + description?: string; + includeTools?: string[]; + excludeTools?: string[]; + }, +) { + const { + scope, + transport, + env, + header, + timeout, + trust, + description, + includeTools, + excludeTools, + } = options; + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const settings = loadSettings(process.cwd()); + + let newServer: Partial = {}; + + const headers = header?.reduce( + (acc, curr) => { + const [key, ...valueParts] = curr.split(':'); + const value = valueParts.join(':').trim(); + if (key.trim() && value) { + acc[key.trim()] = value; + } + return acc; + }, + {} as Record, + ); + + switch (transport) { + case 'sse': + newServer = { + url: commandOrUrl, + headers, + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + case 'http': + newServer = { + httpUrl: commandOrUrl, + headers, + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + case 'stdio': + default: + newServer = { + command: commandOrUrl, + args: args?.map(String), + env: env?.reduce( + (acc, curr) => { + const [key, value] = curr.split('='); + if (key && value) { + acc[key] = value; + } + return acc; + }, + {} as Record, + ), + timeout, + trust, + description, + includeTools, + excludeTools, + }; + break; + } + + const existingSettings = settings.forScope(settingsScope).settings; + const mcpServers = existingSettings.mcpServers || {}; + + const isExistingServer = !!mcpServers[name]; + if (isExistingServer) { + console.log( + `MCP server "${name}" is already configured within ${scope} settings.`, + ); + } + + mcpServers[name] = newServer as MCPServerConfig; + + settings.setValue(settingsScope, 'mcpServers', mcpServers); + + if (isExistingServer) { + console.log(`MCP server "${name}" updated in ${scope} settings.`); + } else { + console.log( + `MCP server "${name}" added to ${scope} settings. (${transport})`, + ); + } +} + +export const addCommand: CommandModule = { + command: 'add [args...]', + describe: 'Add a server', + builder: (yargs) => + yargs + .usage('Usage: gemini mcp add [options] [args...]') + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .positional('commandOrUrl', { + describe: 'Command (stdio) or URL (sse, http)', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'project', + choices: ['user', 'project'], + }) + .option('transport', { + alias: 't', + describe: 'Transport type (stdio, sse, http)', + type: 'string', + default: 'stdio', + choices: ['stdio', 'sse', 'http'], + }) + .option('env', { + alias: 'e', + describe: 'Set environment variables (e.g. -e KEY=value)', + type: 'array', + string: true, + }) + .option('header', { + alias: 'H', + describe: + 'Set HTTP headers for SSE and HTTP transports (e.g. -H "X-Api-Key: abc123" -H "Authorization: Bearer abc123")', + type: 'array', + string: true, + }) + .option('timeout', { + describe: 'Set connection timeout in milliseconds', + type: 'number', + }) + .option('trust', { + describe: + 'Trust the server (bypass all tool call confirmation prompts)', + type: 'boolean', + }) + .option('description', { + describe: 'Set the description for the server', + type: 'string', + }) + .option('include-tools', { + describe: 'A comma-separated list of tools to include', + type: 'array', + string: true, + }) + .option('exclude-tools', { + describe: 'A comma-separated list of tools to exclude', + type: 'array', + string: true, + }), + handler: async (argv) => { + await addMcpServer( + argv.name as string, + argv.commandOrUrl as string, + argv.args as Array, + { + scope: argv.scope as string, + transport: argv.transport as string, + env: argv.env as string[], + header: argv.header as string[], + timeout: argv.timeout as number | undefined, + trust: argv.trust as boolean | undefined, + description: argv.description as string | undefined, + includeTools: argv.includeTools as string[] | undefined, + excludeTools: argv.excludeTools as string[] | undefined, + }, + ); + }, +}; diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts new file mode 100644 index 00000000..daf2e3d7 --- /dev/null +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { listMcpServers } from './list.js'; +import { loadSettings } from '../../config/settings.js'; +import { loadExtensions } from '../../config/extension.js'; +import { createTransport } from '@google/gemini-cli-core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; + +vi.mock('../../config/settings.js'); +vi.mock('../../config/extension.js'); +vi.mock('@google/gemini-cli-core'); +vi.mock('@modelcontextprotocol/sdk/client/index.js'); + +const mockedLoadSettings = loadSettings as vi.Mock; +const mockedLoadExtensions = loadExtensions as vi.Mock; +const mockedCreateTransport = createTransport as vi.Mock; +const MockedClient = Client as vi.Mock; + +interface MockClient { + connect: vi.Mock; + ping: vi.Mock; + close: vi.Mock; +} + +interface MockTransport { + close: vi.Mock; +} + +describe('mcp list command', () => { + let consoleSpy: vi.SpyInstance; + let mockClient: MockClient; + let mockTransport: MockTransport; + + beforeEach(() => { + vi.resetAllMocks(); + + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + mockTransport = { close: vi.fn() }; + mockClient = { + connect: vi.fn(), + ping: vi.fn(), + close: vi.fn(), + }; + + MockedClient.mockImplementation(() => mockClient); + mockedCreateTransport.mockResolvedValue(mockTransport); + mockedLoadExtensions.mockReturnValue([]); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('should display message when no servers configured', async () => { + mockedLoadSettings.mockReturnValue({ merged: { mcpServers: {} } }); + + await listMcpServers(); + + expect(consoleSpy).toHaveBeenCalledWith('No MCP servers configured.'); + }); + + it('should display different server types with connected status', async () => { + mockedLoadSettings.mockReturnValue({ + merged: { + mcpServers: { + 'stdio-server': { command: '/path/to/server', args: ['arg1'] }, + 'sse-server': { url: 'https://example.com/sse' }, + 'http-server': { httpUrl: 'https://example.com/http' }, + }, + }, + }); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(); + + expect(consoleSpy).toHaveBeenCalledWith('Configured MCP servers:\n'); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'stdio-server: /path/to/server arg1 (stdio) - Connected', + ), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'sse-server: https://example.com/sse (sse) - Connected', + ), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'http-server: https://example.com/http (http) - Connected', + ), + ); + }); + + it('should display disconnected status when connection fails', async () => { + mockedLoadSettings.mockReturnValue({ + merged: { + mcpServers: { + 'test-server': { command: '/test/server' }, + }, + }, + }); + + mockClient.connect.mockRejectedValue(new Error('Connection failed')); + + await listMcpServers(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'test-server: /test/server (stdio) - Disconnected', + ), + ); + }); + + it('should merge extension servers with config servers', async () => { + mockedLoadSettings.mockReturnValue({ + merged: { + mcpServers: { 'config-server': { command: '/config/server' } }, + }, + }); + + mockedLoadExtensions.mockReturnValue([ + { + config: { + name: 'test-extension', + mcpServers: { 'extension-server': { command: '/ext/server' } }, + }, + }, + ]); + + mockClient.connect.mockResolvedValue(undefined); + mockClient.ping.mockResolvedValue(undefined); + + await listMcpServers(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'config-server: /config/server (stdio) - Connected', + ), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'extension-server: /ext/server (stdio) - Connected', + ), + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts new file mode 100644 index 00000000..48ea912e --- /dev/null +++ b/packages/cli/src/commands/mcp/list.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp list' command +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { + MCPServerConfig, + MCPServerStatus, + createTransport, +} from '@google/gemini-cli-core'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { loadExtensions } from '../../config/extension.js'; + +const COLOR_GREEN = '\u001b[32m'; +const COLOR_YELLOW = '\u001b[33m'; +const COLOR_RED = '\u001b[31m'; +const RESET_COLOR = '\u001b[0m'; + +async function getMcpServersFromConfig(): Promise< + Record +> { + const settings = loadSettings(process.cwd()); + const extensions = loadExtensions(process.cwd()); + const mcpServers = { ...(settings.merged.mcpServers || {}) }; + for (const extension of extensions) { + Object.entries(extension.config.mcpServers || {}).forEach( + ([key, server]) => { + if (mcpServers[key]) { + return; + } + mcpServers[key] = { + ...server, + extensionName: extension.config.name, + }; + }, + ); + } + return mcpServers; +} + +async function testMCPConnection( + serverName: string, + config: MCPServerConfig, +): Promise { + const client = new Client({ + name: 'mcp-test-client', + version: '0.0.1', + }); + + let transport; + try { + // Use the same transport creation logic as core + transport = await createTransport(serverName, config, false); + } catch (_error) { + await client.close(); + return MCPServerStatus.DISCONNECTED; + } + + try { + // Attempt actual MCP connection with short timeout + await client.connect(transport, { timeout: 5000 }); // 5s timeout + + // Test basic MCP protocol by pinging the server + await client.ping(); + + await client.close(); + return MCPServerStatus.CONNECTED; + } catch (_error) { + await transport.close(); + return MCPServerStatus.DISCONNECTED; + } +} + +async function getServerStatus( + serverName: string, + server: MCPServerConfig, +): Promise { + // Test all server types by attempting actual connection + return await testMCPConnection(serverName, server); +} + +export async function listMcpServers(): Promise { + const mcpServers = await getMcpServersFromConfig(); + const serverNames = Object.keys(mcpServers); + + if (serverNames.length === 0) { + console.log('No MCP servers configured.'); + return; + } + + console.log('Configured MCP servers:\n'); + + for (const serverName of serverNames) { + const server = mcpServers[serverName]; + + const status = await getServerStatus(serverName, server); + + let statusIndicator = ''; + let statusText = ''; + switch (status) { + case MCPServerStatus.CONNECTED: + statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR; + statusText = 'Connected'; + break; + case MCPServerStatus.CONNECTING: + statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR; + statusText = 'Connecting'; + break; + case MCPServerStatus.DISCONNECTED: + default: + statusIndicator = COLOR_RED + '✗' + RESET_COLOR; + statusText = 'Disconnected'; + break; + } + + let serverInfo = `${serverName}: `; + if (server.httpUrl) { + serverInfo += `${server.httpUrl} (http)`; + } else if (server.url) { + serverInfo += `${server.url} (sse)`; + } else if (server.command) { + serverInfo += `${server.command} ${server.args?.join(' ') || ''} (stdio)`; + } + + console.log(`${statusIndicator} ${serverInfo} - ${statusText}`); + } +} + +export const listCommand: CommandModule = { + command: 'list', + describe: 'List all configured MCP servers', + handler: async () => { + await listMcpServers(); + }, +}; diff --git a/packages/cli/src/commands/mcp/remove.test.ts b/packages/cli/src/commands/mcp/remove.test.ts new file mode 100644 index 00000000..eb7dedce --- /dev/null +++ b/packages/cli/src/commands/mcp/remove.test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import yargs from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; +import { removeCommand } from './remove.js'; + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +vi.mock('../../config/settings.js', async () => { + const actual = await vi.importActual('../../config/settings.js'); + return { + ...actual, + loadSettings: vi.fn(), + }; +}); + +const mockedLoadSettings = loadSettings as vi.Mock; + +describe('mcp remove command', () => { + let parser: yargs.Argv; + let mockSetValue: vi.Mock; + let mockSettings: Record; + + beforeEach(() => { + vi.resetAllMocks(); + const yargsInstance = yargs([]).command(removeCommand); + parser = yargsInstance; + mockSetValue = vi.fn(); + mockSettings = { + mcpServers: { + 'test-server': { + command: 'echo "hello"', + }, + }, + }; + mockedLoadSettings.mockReturnValue({ + forScope: () => ({ settings: mockSettings }), + setValue: mockSetValue, + }); + }); + + it('should remove a server from project settings', async () => { + await parser.parseAsync('remove test-server'); + + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'mcpServers', + {}, + ); + }); + + it('should show a message if server not found', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await parser.parseAsync('remove non-existent-server'); + + expect(mockSetValue).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Server "non-existent-server" not found in project settings.', + ); + }); +}); diff --git a/packages/cli/src/commands/mcp/remove.ts b/packages/cli/src/commands/mcp/remove.ts new file mode 100644 index 00000000..80d66234 --- /dev/null +++ b/packages/cli/src/commands/mcp/remove.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// File for 'gemini mcp remove' command +import type { CommandModule } from 'yargs'; +import { loadSettings, SettingScope } from '../../config/settings.js'; + +async function removeMcpServer( + name: string, + options: { + scope: string; + }, +) { + const { scope } = options; + const settingsScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + const settings = loadSettings(process.cwd()); + + const existingSettings = settings.forScope(settingsScope).settings; + const mcpServers = existingSettings.mcpServers || {}; + + if (!mcpServers[name]) { + console.log(`Server "${name}" not found in ${scope} settings.`); + return; + } + + delete mcpServers[name]; + + settings.setValue(settingsScope, 'mcpServers', mcpServers); + + console.log(`Server "${name}" removed from ${scope} settings.`); +} + +export const removeCommand: CommandModule = { + command: 'remove ', + describe: 'Remove a server', + builder: (yargs) => + yargs + .usage('Usage: gemini mcp remove [options] ') + .positional('name', { + describe: 'Name of the server', + type: 'string', + demandOption: true, + }) + .option('scope', { + alias: 's', + describe: 'Configuration scope (user or project)', + type: 'string', + default: 'project', + choices: ['user', 'project'], + }), + handler: async (argv) => { + await removeMcpServer(argv.name as string, { + scope: argv.scope as string, + }); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index beba9602..7175c033 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -10,6 +10,7 @@ import { homedir } from 'node:os'; import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; +import { mcpCommand } from '../commands/mcp.js'; import { Config, loadServerHierarchicalMemory, @@ -72,173 +73,185 @@ export async function parseArguments(): Promise { const yargsInstance = yargs(hideBin(process.argv)) .scriptName('gemini') .usage( - '$0 [options]', - 'Gemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode', + 'Usage: gemini [options] [command]\n\nGemini CLI - Launch an interactive CLI, use -p/--prompt for non-interactive mode', ) - .option('model', { - alias: 'm', - type: 'string', - description: `Model`, - default: process.env.GEMINI_MODEL, - }) - .option('prompt', { - alias: 'p', - type: 'string', - description: 'Prompt. Appended to input on stdin (if any).', - }) - .option('prompt-interactive', { - alias: 'i', - type: 'string', - description: - 'Execute the provided prompt and continue in interactive mode', - }) - .option('sandbox', { - alias: 's', - type: 'boolean', - description: 'Run in sandbox?', - }) - .option('sandbox-image', { - type: 'string', - description: 'Sandbox image URI.', - }) - .option('debug', { - alias: 'd', - type: 'boolean', - description: 'Run in debug mode?', - default: false, - }) - .option('all-files', { - alias: ['a'], - type: 'boolean', - description: 'Include ALL files in context?', - default: false, - }) - .option('all_files', { - type: 'boolean', - description: 'Include ALL files in context?', - default: false, - }) - .deprecateOption( - 'all_files', - 'Use --all-files instead. We will be removing --all_files in the coming weeks.', + .command('$0', 'Launch Gemini CLI', (yargsInstance) => + yargsInstance + .option('model', { + alias: 'm', + type: 'string', + description: `Model`, + default: process.env.GEMINI_MODEL, + }) + .option('prompt', { + alias: 'p', + type: 'string', + description: 'Prompt. Appended to input on stdin (if any).', + }) + .option('prompt-interactive', { + alias: 'i', + type: 'string', + description: + 'Execute the provided prompt and continue in interactive mode', + }) + .option('sandbox', { + alias: 's', + type: 'boolean', + description: 'Run in sandbox?', + }) + .option('sandbox-image', { + type: 'string', + description: 'Sandbox image URI.', + }) + .option('debug', { + alias: 'd', + type: 'boolean', + description: 'Run in debug mode?', + default: false, + }) + .option('all-files', { + alias: ['a'], + type: 'boolean', + description: 'Include ALL files in context?', + default: false, + }) + .option('all_files', { + type: 'boolean', + description: 'Include ALL files in context?', + default: false, + }) + .deprecateOption( + 'all_files', + 'Use --all-files instead. We will be removing --all_files in the coming weeks.', + ) + .option('show-memory-usage', { + type: 'boolean', + description: 'Show memory usage in status bar', + default: false, + }) + .option('show_memory_usage', { + type: 'boolean', + description: 'Show memory usage in status bar', + default: false, + }) + .deprecateOption( + 'show_memory_usage', + 'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.', + ) + .option('yolo', { + alias: 'y', + type: 'boolean', + description: + 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', + default: false, + }) + .option('telemetry', { + type: 'boolean', + description: + 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.', + }) + .option('telemetry-target', { + type: 'string', + choices: ['local', 'gcp'], + description: + 'Set the telemetry target (local or gcp). Overrides settings files.', + }) + .option('telemetry-otlp-endpoint', { + type: 'string', + description: + 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.', + }) + .option('telemetry-log-prompts', { + type: 'boolean', + description: + 'Enable or disable logging of user prompts for telemetry. Overrides settings files.', + }) + .option('telemetry-outfile', { + type: 'string', + description: 'Redirect all telemetry output to the specified file.', + }) + .option('checkpointing', { + alias: 'c', + type: 'boolean', + description: 'Enables checkpointing of file edits', + default: false, + }) + .option('experimental-acp', { + type: 'boolean', + description: 'Starts the agent in ACP mode', + }) + .option('allowed-mcp-server-names', { + type: 'array', + string: true, + description: 'Allowed MCP server names', + }) + .option('extensions', { + alias: 'e', + type: 'array', + string: true, + description: + 'A list of extensions to use. If not provided, all extensions are used.', + }) + .option('list-extensions', { + alias: 'l', + type: 'boolean', + description: 'List all available extensions and exit.', + }) + .option('ide-mode-feature', { + type: 'boolean', + description: 'Run in IDE mode?', + }) + .option('proxy', { + type: 'string', + description: + 'Proxy for gemini client, like schema://user:password@host:port', + }) + .option('include-directories', { + type: 'array', + string: true, + description: + 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', + coerce: (dirs: string[]) => + // Handle comma-separated values + dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), + }) + .option('load-memory-from-include-directories', { + type: 'boolean', + description: + 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.', + default: false, + }) + .check((argv) => { + if (argv.prompt && argv.promptInteractive) { + throw new Error( + 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', + ); + } + return true; + }), ) - .option('show-memory-usage', { - type: 'boolean', - description: 'Show memory usage in status bar', - default: false, - }) - .option('show_memory_usage', { - type: 'boolean', - description: 'Show memory usage in status bar', - default: false, - }) - .deprecateOption( - 'show_memory_usage', - 'Use --show-memory-usage instead. We will be removing --show_memory_usage in the coming weeks.', - ) - .option('yolo', { - alias: 'y', - type: 'boolean', - description: - 'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?', - default: false, - }) - .option('telemetry', { - type: 'boolean', - description: - 'Enable telemetry? This flag specifically controls if telemetry is sent. Other --telemetry-* flags set specific values but do not enable telemetry on their own.', - }) - .option('telemetry-target', { - type: 'string', - choices: ['local', 'gcp'], - description: - 'Set the telemetry target (local or gcp). Overrides settings files.', - }) - .option('telemetry-otlp-endpoint', { - type: 'string', - description: - 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.', - }) - .option('telemetry-log-prompts', { - type: 'boolean', - description: - 'Enable or disable logging of user prompts for telemetry. Overrides settings files.', - }) - .option('telemetry-outfile', { - type: 'string', - description: 'Redirect all telemetry output to the specified file.', - }) - .option('checkpointing', { - alias: 'c', - type: 'boolean', - description: 'Enables checkpointing of file edits', - default: false, - }) - .option('experimental-acp', { - type: 'boolean', - description: 'Starts the agent in ACP mode', - }) - .option('allowed-mcp-server-names', { - type: 'array', - string: true, - description: 'Allowed MCP server names', - }) - .option('extensions', { - alias: 'e', - type: 'array', - string: true, - description: - 'A list of extensions to use. If not provided, all extensions are used.', - }) - .option('list-extensions', { - alias: 'l', - type: 'boolean', - description: 'List all available extensions and exit.', - }) - .option('ide-mode-feature', { - type: 'boolean', - description: 'Run in IDE mode?', - }) - .option('proxy', { - type: 'string', - description: - 'Proxy for gemini client, like schema://user:password@host:port', - }) - .option('include-directories', { - type: 'array', - string: true, - description: - 'Additional directories to include in the workspace (comma-separated or multiple --include-directories)', - coerce: (dirs: string[]) => - // Handle comma-separated values - dirs.flatMap((dir) => dir.split(',').map((d) => d.trim())), - }) - .option('load-memory-from-include-directories', { - type: 'boolean', - description: - 'If true, when refreshing memory, GEMINI.md files should be loaded from all directories that are added. If false, GEMINI.md files should only be loaded from the primary working directory.', - default: false, - }) + // Register MCP subcommands + .command(mcpCommand) .version(await getCliVersion()) // This will enable the --version flag based on package.json .alias('v', 'version') .help() .alias('h', 'help') .strict() - .check((argv) => { - if (argv.prompt && argv.promptInteractive) { - throw new Error( - 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together', - ); - } - return true; - }); + .demandCommand(0, 0); // Allow base command to run with no subcommands yargsInstance.wrap(yargsInstance.terminalWidth()); - const result = yargsInstance.parseSync(); + const result = await yargsInstance.parse(); + + // Handle case where MCP subcommands are executed - they should exit the process + // and not return to main CLI logic + if (result._.length > 0 && result._[0] === 'mcp') { + // MCP commands handle their own execution and process exit + process.exit(0); + } // The import format is now only controlled by settings.memoryImportFormat // We no longer accept it as a CLI argument - return result as CliArgs; + return result as unknown as CliArgs; } // This function is now a thin wrapper around the server's implementation. From 487818df276dc66827ce0f469f84bcae56a70801 Mon Sep 17 00:00:00 2001 From: Akhil Appana Date: Wed, 6 Aug 2025 10:19:43 -0700 Subject: [PATCH 013/107] fix: improve error handling and path processing in memory discovery (#5175) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Allen Hutchison --- packages/core/src/utils/memoryDiscovery.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index f53d27a9..fcb1abdd 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -336,5 +336,8 @@ export async function loadServerHierarchicalMemory( logger.debug( `Combined instructions (snippet): ${combinedInstructions.substring(0, 500)}...`, ); - return { memoryContent: combinedInstructions, fileCount: filePaths.length }; + return { + memoryContent: combinedInstructions, + fileCount: contentsWithPaths.length, + }; } From fde9849d48e3b92377aca2eecfd390ebce288692 Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 6 Aug 2025 17:36:05 +0000 Subject: [PATCH 014/107] [ide-mode] Add support for in-IDE diff handling in the CLI (#5603) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../messages/ToolConfirmationMessage.tsx | 41 +++++-- .../core/src/core/coreToolScheduler.test.ts | 2 + packages/core/src/core/coreToolScheduler.ts | 24 ++++ packages/core/src/ide/ide-client.ts | 106 +++++++++++++++++- packages/core/src/ide/ideContext.ts | 59 ++++++++++ packages/core/src/tools/edit.ts | 1 + packages/core/src/tools/memoryTool.ts | 1 + packages/core/src/tools/tools.ts | 3 + packages/core/src/tools/write-file.test.ts | 29 ++++- packages/core/src/tools/write-file.ts | 18 +++ .../vscode-ide-companion/src/diff-manager.ts | 69 +++++++----- .../vscode-ide-companion/src/ide-server.ts | 22 ++-- 12 files changed, 323 insertions(+), 52 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 197a922c..8b7f93d1 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -33,6 +33,7 @@ export const ToolConfirmationMessage: React.FC< ToolConfirmationMessageProps > = ({ confirmationDetails, + config, isFocused = true, availableTerminalHeight, terminalWidth, @@ -40,14 +41,29 @@ export const ToolConfirmationMessage: React.FC< const { onConfirm } = confirmationDetails; const childWidth = terminalWidth - 2; // 2 for padding + const handleConfirm = async (outcome: ToolConfirmationOutcome) => { + if (confirmationDetails.type === 'edit') { + const ideClient = config?.getIdeClient(); + if (config?.getIdeMode() && config?.getIdeModeFeature()) { + const cliOutcome = + outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted'; + await ideClient?.resolveDiffFromCli( + confirmationDetails.filePath, + cliOutcome, + ); + } + } + onConfirm(outcome); + }; + useInput((_, key) => { if (!isFocused) return; if (key.escape) { - onConfirm(ToolConfirmationOutcome.Cancel); + handleConfirm(ToolConfirmationOutcome.Cancel); } }); - const handleSelect = (item: ToolConfirmationOutcome) => onConfirm(item); + const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item); let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here let question: string; @@ -85,6 +101,7 @@ export const ToolConfirmationMessage: React.FC< HEIGHT_OPTIONS; return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); } + if (confirmationDetails.type === 'edit') { if (confirmationDetails.isModifying) { return ( @@ -114,15 +131,25 @@ export const ToolConfirmationMessage: React.FC< label: 'Yes, allow always', value: ToolConfirmationOutcome.ProceedAlways, }, - { + ); + if (config?.getIdeMode() && config?.getIdeModeFeature()) { + options.push({ + label: 'No', + value: ToolConfirmationOutcome.Cancel, + }); + } else { + // TODO(chrstnb): support edit tool in IDE mode. + + options.push({ label: 'Modify with external editor', value: ToolConfirmationOutcome.ModifyWithEditor, - }, - { + }); + options.push({ label: 'No, suggest changes (esc)', value: ToolConfirmationOutcome.Cancel, - }, - ); + }); + } + bodyContent = ( { type: 'edit', title: 'Confirm Edit', fileName: 'test.txt', + filePath: 'test.txt', fileDiff: '--- test.txt\n+++ test.txt\n@@ -1,1 +1,1 @@\n-old content\n+new content', originalContent: 'old content', diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 5f2cc895..f54aa532 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -476,6 +476,30 @@ export class CoreToolScheduler { ); if (confirmationDetails) { + // Allow IDE to resolve confirmation + if ( + confirmationDetails.type === 'edit' && + confirmationDetails.ideConfirmation + ) { + confirmationDetails.ideConfirmation.then((resolution) => { + if (resolution.status === 'accepted') { + this.handleConfirmationResponse( + reqInfo.callId, + confirmationDetails.onConfirm, + ToolConfirmationOutcome.ProceedOnce, + signal, + ); + } else { + this.handleConfirmationResponse( + reqInfo.callId, + confirmationDetails.onConfirm, + ToolConfirmationOutcome.Cancel, + signal, + ); + } + }); + } + const originalOnConfirm = confirmationDetails.onConfirm; const wrappedConfirmationDetails: ToolCallConfirmationDetails = { ...confirmationDetails, diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 8f967147..42b79c44 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -9,7 +9,14 @@ import { DetectedIde, getIdeDisplayName, } from '../ide/detect-ide.js'; -import { ideContext, IdeContextNotificationSchema } from '../ide/ideContext.js'; +import { + ideContext, + IdeContextNotificationSchema, + IdeDiffAcceptedNotificationSchema, + IdeDiffClosedNotificationSchema, + CloseDiffResponseSchema, + DiffUpdateResult, +} from '../ide/ideContext.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -42,6 +49,7 @@ export class IdeClient { }; private readonly currentIde: DetectedIde | undefined; private readonly currentIdeDisplayName: string | undefined; + private diffResponses = new Map void>(); private constructor() { this.currentIde = detectIde(); @@ -77,6 +85,75 @@ export class IdeClient { await this.establishConnection(port); } + /** + * A diff is accepted with any modifications if the user performs one of the + * following actions: + * - Clicks the checkbox icon in the IDE to accept + * - Runs `command+shift+p` > "Gemini CLI: Accept Diff in IDE" to accept + * - Selects "accept" in the CLI UI + * - Saves the file via `ctrl/command+s` + * + * A diff is rejected if the user performs one of the following actions: + * - Clicks the "x" icon in the IDE + * - Runs "Gemini CLI: Close Diff in IDE" + * - Selects "no" in the CLI UI + * - Closes the file + */ + async openDiff( + filePath: string, + newContent?: string, + ): Promise { + return new Promise((resolve, reject) => { + this.diffResponses.set(filePath, resolve); + this.client + ?.callTool({ + name: `openDiff`, + arguments: { + filePath, + newContent, + }, + }) + .catch((err) => { + logger.debug(`callTool for ${filePath} failed:`, err); + reject(err); + }); + }); + } + + async closeDiff(filePath: string): Promise { + try { + const result = await this.client?.callTool({ + name: `closeDiff`, + arguments: { + filePath, + }, + }); + + if (result) { + const parsed = CloseDiffResponseSchema.parse(result); + return parsed.content; + } + } catch (err) { + logger.debug(`callTool for ${filePath} failed:`, err); + } + return; + } + + // Closes the diff. Instead of waiting for a notification, + // manually resolves the diff resolver as the desired outcome. + async resolveDiffFromCli(filePath: string, outcome: 'accepted' | 'rejected') { + const content = await this.closeDiff(filePath); + const resolver = this.diffResponses.get(filePath); + if (resolver) { + if (outcome === 'accepted') { + resolver({ status: 'accepted', content }); + } else { + resolver({ status: 'rejected', content: undefined }); + } + this.diffResponses.delete(filePath); + } + } + disconnect() { this.setState( IDEConnectionStatus.Disconnected, @@ -175,6 +252,33 @@ export class IdeClient { `IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`, ); }; + this.client.setNotificationHandler( + IdeDiffAcceptedNotificationSchema, + (notification) => { + const { filePath, content } = notification.params; + const resolver = this.diffResponses.get(filePath); + if (resolver) { + resolver({ status: 'accepted', content }); + this.diffResponses.delete(filePath); + } else { + logger.debug(`No resolver found for ${filePath}`); + } + }, + ); + + this.client.setNotificationHandler( + IdeDiffClosedNotificationSchema, + (notification) => { + const { filePath } = notification.params; + const resolver = this.diffResponses.get(filePath); + if (resolver) { + resolver({ status: 'rejected', content: undefined }); + this.diffResponses.delete(filePath); + } else { + logger.debug(`No resolver found for ${filePath}`); + } + }, + ); } private async establishConnection(port: string) { diff --git a/packages/core/src/ide/ideContext.ts b/packages/core/src/ide/ideContext.ts index 588e25ee..3052c029 100644 --- a/packages/core/src/ide/ideContext.ts +++ b/packages/core/src/ide/ideContext.ts @@ -36,10 +36,69 @@ export type IdeContext = z.infer; * Zod schema for validating the 'ide/contextUpdate' notification from the IDE. */ export const IdeContextNotificationSchema = z.object({ + jsonrpc: z.literal('2.0'), method: z.literal('ide/contextUpdate'), params: IdeContextSchema, }); +export const IdeDiffAcceptedNotificationSchema = z.object({ + jsonrpc: z.literal('2.0'), + method: z.literal('ide/diffAccepted'), + params: z.object({ + filePath: z.string(), + content: z.string(), + }), +}); + +export const IdeDiffClosedNotificationSchema = z.object({ + jsonrpc: z.literal('2.0'), + method: z.literal('ide/diffClosed'), + params: z.object({ + filePath: z.string(), + content: z.string().optional(), + }), +}); + +export const CloseDiffResponseSchema = z + .object({ + content: z + .array( + z.object({ + text: z.string(), + type: z.literal('text'), + }), + ) + .min(1), + }) + .transform((val, ctx) => { + try { + const parsed = JSON.parse(val.content[0].text); + const innerSchema = z.object({ content: z.string().optional() }); + const validationResult = innerSchema.safeParse(parsed); + if (!validationResult.success) { + validationResult.error.issues.forEach((issue) => ctx.addIssue(issue)); + return z.NEVER; + } + return validationResult.data; + } catch (_) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid JSON in text content', + }); + return z.NEVER; + } + }); + +export type DiffUpdateResult = + | { + status: 'accepted'; + content?: string; + } + | { + status: 'rejected'; + content: undefined; + }; + type IdeContextSubscriber = (ideContext: IdeContext | undefined) => void; /** diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 25da2292..0d129e42 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -332,6 +332,7 @@ Expectation for required parameters: type: 'edit', title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`, fileName, + filePath: params.file_path, fileDiff, originalContent: editData.currentContent, newContent: editData.newContent, diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 96509f79..847ea5cf 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -220,6 +220,7 @@ export class MemoryTool type: 'edit', title: `Confirm Memory Save: ${tildeifyPath(memoryFilePath)}`, fileName: memoryFilePath, + filePath: memoryFilePath, fileDiff, originalContent: currentContent, newContent, diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 5d9d9253..3404093f 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -6,6 +6,7 @@ import { FunctionDeclaration, PartListUnion, Schema } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; +import { DiffUpdateResult } from '../ide/ideContext.js'; /** * Interface representing the base Tool functionality @@ -330,10 +331,12 @@ export interface ToolEditConfirmationDetails { payload?: ToolConfirmationPayload, ) => Promise; fileName: string; + filePath: string; fileDiff: string; originalContent: string | null; newContent: string; isModifying?: boolean; + ideConfirmation?: Promise; } export interface ToolConfirmationPayload { diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index fe662a02..563579bb 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -55,6 +55,9 @@ const mockConfigInternal = { getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), setApprovalMode: vi.fn(), getGeminiClient: vi.fn(), // Initialize as a plain mock function + getIdeClient: vi.fn(), + getIdeMode: vi.fn(() => false), + getIdeModeFeature: vi.fn(() => false), getWorkspaceContext: () => createMockWorkspaceContext(rootDir), getApiKey: () => 'test-key', getModel: () => 'test-model', @@ -110,6 +113,14 @@ describe('WriteFileTool', () => { mockConfigInternal.getGeminiClient.mockReturnValue( mockGeminiClientInstance, ); + mockConfigInternal.getIdeClient.mockReturnValue({ + openDiff: vi.fn(), + closeDiff: vi.fn(), + getIdeContext: vi.fn(), + subscribeToIdeContext: vi.fn(), + isCodeTrackerEnabled: vi.fn(), + getTrackedCode: vi.fn(), + }); tool = new WriteFileTool(mockConfig); @@ -500,7 +511,11 @@ describe('WriteFileTool', () => { params, abortSignal, ); - if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) { + if ( + typeof confirmDetails === 'object' && + 'onConfirm' in confirmDetails && + confirmDetails.onConfirm + ) { await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); } @@ -554,7 +569,11 @@ describe('WriteFileTool', () => { params, abortSignal, ); - if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) { + if ( + typeof confirmDetails === 'object' && + 'onConfirm' in confirmDetails && + confirmDetails.onConfirm + ) { await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); } @@ -595,7 +614,11 @@ describe('WriteFileTool', () => { params, abortSignal, ); - if (typeof confirmDetails === 'object' && confirmDetails.onConfirm) { + if ( + typeof confirmDetails === 'object' && + 'onConfirm' in confirmDetails && + confirmDetails.onConfirm + ) { await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); } diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 1cb1a917..32ecc068 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -32,6 +32,7 @@ import { recordFileOperationMetric, FileOperation, } from '../telemetry/metrics.js'; +import { IDEConnectionStatus } from '../ide/ide-client.js'; /** * Parameters for the WriteFile tool @@ -184,10 +185,19 @@ export class WriteFileTool DEFAULT_DIFF_OPTIONS, ); + const ideClient = this.config.getIdeClient(); + const ideConfirmation = + this.config.getIdeModeFeature() && + this.config.getIdeMode() && + ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected + ? ideClient.openDiff(params.file_path, correctedContent) + : undefined; + const confirmationDetails: ToolEditConfirmationDetails = { type: 'edit', title: `Confirm Write: ${shortenPath(relativePath)}`, fileName, + filePath: params.file_path, fileDiff, originalContent, newContent: correctedContent, @@ -195,7 +205,15 @@ export class WriteFileTool if (outcome === ToolConfirmationOutcome.ProceedAlways) { this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); } + + if (ideConfirmation) { + const result = await ideConfirmation; + if (result.status === 'accepted' && result.content) { + params.content = result.content; + } + } }, + ideConfirmation, }; return confirmationDetails; } diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts index 159a6101..0dad03a6 100644 --- a/packages/vscode-ide-companion/src/diff-manager.ts +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -4,10 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as vscode from 'vscode'; -import * as path from 'node:path'; -import { DIFF_SCHEME } from './extension.js'; +import { + IdeDiffAcceptedNotificationSchema, + IdeDiffClosedNotificationSchema, +} from '@google/gemini-cli-core'; import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { DIFF_SCHEME } from './extension.js'; export class DiffContentProvider implements vscode.TextDocumentContentProvider { private content = new Map(); @@ -126,18 +130,19 @@ export class DiffManager { const rightDoc = await vscode.workspace.openTextDocument(uriToClose); const modifiedContent = rightDoc.getText(); await this.closeDiffEditor(uriToClose); - this.onDidChangeEmitter.fire({ - jsonrpc: '2.0', - method: 'ide/diffClosed', - params: { - filePath, - content: modifiedContent, - }, - }); - vscode.window.showInformationMessage(`Diff for ${filePath} closed.`); - } else { - vscode.window.showWarningMessage(`No open diff found for ${filePath}.`); + this.onDidChangeEmitter.fire( + IdeDiffClosedNotificationSchema.parse({ + jsonrpc: '2.0', + method: 'ide/diffClosed', + params: { + filePath, + content: modifiedContent, + }, + }), + ); + return modifiedContent; } + return; } /** @@ -156,14 +161,16 @@ export class DiffManager { const modifiedContent = rightDoc.getText(); await this.closeDiffEditor(rightDocUri); - this.onDidChangeEmitter.fire({ - jsonrpc: '2.0', - method: 'ide/diffAccepted', - params: { - filePath: diffInfo.originalFilePath, - content: modifiedContent, - }, - }); + this.onDidChangeEmitter.fire( + IdeDiffAcceptedNotificationSchema.parse({ + jsonrpc: '2.0', + method: 'ide/diffAccepted', + params: { + filePath: diffInfo.originalFilePath, + content: modifiedContent, + }, + }), + ); } /** @@ -184,14 +191,16 @@ export class DiffManager { const modifiedContent = rightDoc.getText(); await this.closeDiffEditor(rightDocUri); - this.onDidChangeEmitter.fire({ - jsonrpc: '2.0', - method: 'ide/diffClosed', - params: { - filePath: diffInfo.originalFilePath, - content: modifiedContent, - }, - }); + this.onDidChangeEmitter.fire( + IdeDiffClosedNotificationSchema.parse({ + jsonrpc: '2.0', + method: 'ide/diffClosed', + params: { + filePath: diffInfo.originalFilePath, + content: modifiedContent, + }, + }), + ); } private addDiffDocument(uri: vscode.Uri, diffInfo: DiffInfo) { diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts index 30215ccc..eec99cb3 100644 --- a/packages/vscode-ide-companion/src/ide-server.ts +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -5,15 +5,13 @@ */ import * as vscode from 'vscode'; +import { IdeContextNotificationSchema } from '@google/gemini-cli-core'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import express, { Request, Response } from 'express'; +import express, { type Request, type Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { - isInitializeRequest, - type JSONRPCNotification, -} from '@modelcontextprotocol/sdk/types.js'; -import { Server as HTTPServer } from 'node:http'; +import { type Server as HTTPServer } from 'node:http'; import { z } from 'zod'; import { DiffManager } from './diff-manager.js'; import { OpenFilesManager } from './open-files-manager.js'; @@ -28,11 +26,12 @@ function sendIdeContextUpdateNotification( ) { const ideContext = openFilesManager.state; - const notification: JSONRPCNotification = { + const notification = IdeContextNotificationSchema.parse({ jsonrpc: '2.0', method: 'ide/contextUpdate', params: ideContext, - }; + }); + log( `Sending IDE context update notification: ${JSON.stringify( notification, @@ -76,7 +75,7 @@ export class IDEServer { }); context.subscriptions.push(onDidChangeSubscription); const onDidChangeDiffSubscription = this.diffManager.onDidChange( - (notification: JSONRPCNotification) => { + (notification) => { for (const transport of Object.values(transports)) { transport.send(notification); } @@ -269,12 +268,13 @@ const createMcpServer = (diffManager: DiffManager) => { }).shape, }, async ({ filePath }: { filePath: string }) => { - await diffManager.closeDiff(filePath); + const content = await diffManager.closeDiff(filePath); + const response = { content: content ?? undefined }; return { content: [ { type: 'text', - text: `Closed diff for ${filePath}`, + text: JSON.stringify(response), }, ], }; From 882a97aff998b2f19731e9966d135f1db5a59914 Mon Sep 17 00:00:00 2001 From: agarwalravikant Date: Wed, 6 Aug 2025 23:16:42 +0530 Subject: [PATCH 015/107] =?UTF-8?q?Fix=20to=20send=20user=20tool=20confirm?= =?UTF-8?q?ation=20decision=20for=20yolo=20or=20non=20interacti=E2=80=A6?= =?UTF-8?q?=20(#5677)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ravikant Agarwal --- packages/core/src/core/coreToolScheduler.ts | 26 ++++++++++++++----- .../src/core/nonInteractiveToolExecutor.ts | 2 ++ packages/core/src/telemetry/metrics.ts | 2 +- packages/core/src/telemetry/types.ts | 4 ++- .../core/src/telemetry/uiTelemetry.test.ts | 6 +++++ packages/core/src/telemetry/uiTelemetry.ts | 4 +++ 6 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index f54aa532..9b999b6b 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -468,6 +468,10 @@ export class CoreToolScheduler { const { request: reqInfo, tool: toolInstance } = toolCall; try { if (this.config.getApprovalMode() === ApprovalMode.YOLO) { + this.setToolCallOutcome( + reqInfo.callId, + ToolConfirmationOutcome.ProceedAlways, + ); this.setStatusInternal(reqInfo.callId, 'scheduled'); } else { const confirmationDetails = await toolInstance.shouldConfirmExecute( @@ -521,6 +525,10 @@ export class CoreToolScheduler { wrappedConfirmationDetails, ); } else { + this.setToolCallOutcome( + reqInfo.callId, + ToolConfirmationOutcome.ProceedAlways, + ); this.setStatusInternal(reqInfo.callId, 'scheduled'); } } @@ -555,13 +563,7 @@ export class CoreToolScheduler { await originalOnConfirm(outcome); } - this.toolCalls = this.toolCalls.map((call) => { - if (call.request.callId !== callId) return call; - return { - ...call, - outcome, - }; - }); + this.setToolCallOutcome(callId, outcome); if (outcome === ToolConfirmationOutcome.Cancel || signal.aborted) { this.setStatusInternal( @@ -774,4 +776,14 @@ export class CoreToolScheduler { this.onToolCallsUpdate([...this.toolCalls]); } } + + private setToolCallOutcome(callId: string, outcome: ToolConfirmationOutcome) { + this.toolCalls = this.toolCalls.map((call) => { + if (call.request.callId !== callId) return call; + return { + ...call, + outcome, + }; + }); + } } diff --git a/packages/core/src/core/nonInteractiveToolExecutor.ts b/packages/core/src/core/nonInteractiveToolExecutor.ts index 52704bf1..ed235cd3 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.ts @@ -14,6 +14,7 @@ import { } from '../index.js'; import { Config } from '../config/config.js'; import { convertToFunctionResponse } from './coreToolScheduler.js'; +import { ToolCallDecision } from '../telemetry/types.js'; /** * Executes a single tool call non-interactively. @@ -87,6 +88,7 @@ export async function executeToolCall( error_type: toolResult.error === undefined ? undefined : toolResult.error.type, prompt_id: toolCallRequest.prompt_id, + decision: ToolCallDecision.AUTO_ACCEPT, }); const response = convertToFunctionResponse( diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 124bc602..103f7f71 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -100,7 +100,7 @@ export function recordToolCallMetrics( functionName: string, durationMs: number, success: boolean, - decision?: 'accept' | 'reject' | 'modify', + decision?: 'accept' | 'reject' | 'modify' | 'auto_accept', ): void { if (!toolCallCounter || !toolCallLatencyHistogram || !isMetricsInitialized) return; diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 668421f0..db84e2da 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -14,6 +14,7 @@ export enum ToolCallDecision { ACCEPT = 'accept', REJECT = 'reject', MODIFY = 'modify', + AUTO_ACCEPT = 'auto_accept', } export function getDecisionFromOutcome( @@ -21,10 +22,11 @@ export function getDecisionFromOutcome( ): ToolCallDecision { switch (outcome) { case ToolConfirmationOutcome.ProceedOnce: + return ToolCallDecision.ACCEPT; case ToolConfirmationOutcome.ProceedAlways: case ToolConfirmationOutcome.ProceedAlwaysServer: case ToolConfirmationOutcome.ProceedAlwaysTool: - return ToolCallDecision.ACCEPT; + return ToolCallDecision.AUTO_ACCEPT; case ToolConfirmationOutcome.ModifyWithEditor: return ToolCallDecision.MODIFY; case ToolConfirmationOutcome.Cancel: diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index bce54ad8..221804d2 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -104,6 +104,7 @@ describe('UiTelemetryService', () => { [ToolCallDecision.ACCEPT]: 0, [ToolCallDecision.REJECT]: 0, [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, }, byName: {}, }, @@ -362,6 +363,7 @@ describe('UiTelemetryService', () => { [ToolCallDecision.ACCEPT]: 1, [ToolCallDecision.REJECT]: 0, [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, }, }); }); @@ -395,6 +397,7 @@ describe('UiTelemetryService', () => { [ToolCallDecision.ACCEPT]: 0, [ToolCallDecision.REJECT]: 1, [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, }, }); }); @@ -434,11 +437,13 @@ describe('UiTelemetryService', () => { [ToolCallDecision.ACCEPT]: 0, [ToolCallDecision.REJECT]: 0, [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, }); expect(tools.byName['test_tool'].decisions).toEqual({ [ToolCallDecision.ACCEPT]: 0, [ToolCallDecision.REJECT]: 0, [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, }); }); @@ -483,6 +488,7 @@ describe('UiTelemetryService', () => { [ToolCallDecision.ACCEPT]: 1, [ToolCallDecision.REJECT]: 1, [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, }, }); }); diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 2713ac65..fbf5b8dc 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -32,6 +32,7 @@ export interface ToolCallStats { [ToolCallDecision.ACCEPT]: number; [ToolCallDecision.REJECT]: number; [ToolCallDecision.MODIFY]: number; + [ToolCallDecision.AUTO_ACCEPT]: number; }; } @@ -62,6 +63,7 @@ export interface SessionMetrics { [ToolCallDecision.ACCEPT]: number; [ToolCallDecision.REJECT]: number; [ToolCallDecision.MODIFY]: number; + [ToolCallDecision.AUTO_ACCEPT]: number; }; byName: Record; }; @@ -94,6 +96,7 @@ const createInitialMetrics = (): SessionMetrics => ({ [ToolCallDecision.ACCEPT]: 0, [ToolCallDecision.REJECT]: 0, [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, }, byName: {}, }, @@ -192,6 +195,7 @@ export class UiTelemetryService extends EventEmitter { [ToolCallDecision.ACCEPT]: 0, [ToolCallDecision.REJECT]: 0, [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, }, }; } From 6133bea388a2de69c71a6be6f1450707f2ce4dfb Mon Sep 17 00:00:00 2001 From: joshualitt Date: Wed, 6 Aug 2025 10:50:02 -0700 Subject: [PATCH 016/107] feat(core): Introduce `DeclarativeTool` and `ToolInvocation`. (#5613) --- packages/cli/src/acp/acpPeer.ts | 159 ++++---- .../cli/src/ui/hooks/atCommandProcessor.ts | 13 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 56 ++- .../cli/src/ui/hooks/useReactToolScheduler.ts | 27 +- .../cli/src/ui/hooks/useToolScheduler.test.ts | 180 +++++----- packages/core/src/core/client.ts | 18 +- .../core/src/core/coreToolScheduler.test.ts | 83 ++--- packages/core/src/core/coreToolScheduler.ts | 110 +++++- .../core/nonInteractiveToolExecutor.test.ts | 81 ++--- .../src/core/nonInteractiveToolExecutor.ts | 2 +- .../src/telemetry/loggers.test.circular.ts | 10 +- packages/core/src/telemetry/loggers.test.ts | 7 +- .../core/src/telemetry/uiTelemetry.test.ts | 8 +- packages/core/src/test-utils/tools.ts | 63 ++++ packages/core/src/tools/edit.ts | 4 +- packages/core/src/tools/memoryTool.ts | 4 +- .../core/src/tools/modifiable-tool.test.ts | 12 +- packages/core/src/tools/modifiable-tool.ts | 18 +- packages/core/src/tools/read-file.test.ts | 338 +++++++++--------- packages/core/src/tools/read-file.ts | 138 +++---- packages/core/src/tools/tool-registry.test.ts | 24 +- packages/core/src/tools/tool-registry.ts | 14 +- packages/core/src/tools/tools.ts | 299 ++++++++++++---- packages/core/src/tools/write-file.ts | 4 +- 24 files changed, 991 insertions(+), 681 deletions(-) create mode 100644 packages/core/src/test-utils/tools.ts diff --git a/packages/cli/src/acp/acpPeer.ts b/packages/cli/src/acp/acpPeer.ts index 90952b7f..40d8753f 100644 --- a/packages/cli/src/acp/acpPeer.ts +++ b/packages/cli/src/acp/acpPeer.ts @@ -239,65 +239,62 @@ class GeminiAgent implements Agent { ); } - let toolCallId; - const confirmationDetails = await tool.shouldConfirmExecute( - args, - abortSignal, - ); - if (confirmationDetails) { - let content: acp.ToolCallContent | null = null; - if (confirmationDetails.type === 'edit') { - content = { - type: 'diff', - path: confirmationDetails.fileName, - oldText: confirmationDetails.originalContent, - newText: confirmationDetails.newContent, - }; - } - - const result = await this.client.requestToolCallConfirmation({ - label: tool.getDescription(args), - icon: tool.icon, - content, - confirmation: toAcpToolCallConfirmation(confirmationDetails), - locations: tool.toolLocations(args), - }); - - await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome)); - switch (result.outcome) { - case 'reject': - return errorResponse( - new Error(`Tool "${fc.name}" not allowed to run by the user.`), - ); - - case 'cancel': - return errorResponse( - new Error(`Tool "${fc.name}" was canceled by the user.`), - ); - case 'allow': - case 'alwaysAllow': - case 'alwaysAllowMcpServer': - case 'alwaysAllowTool': - break; - default: { - const resultOutcome: never = result.outcome; - throw new Error(`Unexpected: ${resultOutcome}`); - } - } - - toolCallId = result.id; - } else { - const result = await this.client.pushToolCall({ - icon: tool.icon, - label: tool.getDescription(args), - locations: tool.toolLocations(args), - }); - - toolCallId = result.id; - } - + let toolCallId: number | undefined = undefined; try { - const toolResult: ToolResult = await tool.execute(args, abortSignal); + const invocation = tool.build(args); + const confirmationDetails = + await invocation.shouldConfirmExecute(abortSignal); + if (confirmationDetails) { + let content: acp.ToolCallContent | null = null; + if (confirmationDetails.type === 'edit') { + content = { + type: 'diff', + path: confirmationDetails.fileName, + oldText: confirmationDetails.originalContent, + newText: confirmationDetails.newContent, + }; + } + + const result = await this.client.requestToolCallConfirmation({ + label: invocation.getDescription(), + icon: tool.icon, + content, + confirmation: toAcpToolCallConfirmation(confirmationDetails), + locations: invocation.toolLocations(), + }); + + await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome)); + switch (result.outcome) { + case 'reject': + return errorResponse( + new Error(`Tool "${fc.name}" not allowed to run by the user.`), + ); + + case 'cancel': + return errorResponse( + new Error(`Tool "${fc.name}" was canceled by the user.`), + ); + case 'allow': + case 'alwaysAllow': + case 'alwaysAllowMcpServer': + case 'alwaysAllowTool': + break; + default: { + const resultOutcome: never = result.outcome; + throw new Error(`Unexpected: ${resultOutcome}`); + } + } + toolCallId = result.id; + } else { + const result = await this.client.pushToolCall({ + icon: tool.icon, + label: invocation.getDescription(), + locations: invocation.toolLocations(), + }); + toolCallId = result.id; + } + + const toolResult: ToolResult = await invocation.execute(abortSignal); const toolCallContent = toToolCallContent(toolResult); await this.client.updateToolCall({ @@ -320,12 +317,13 @@ class GeminiAgent implements Agent { return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); - await this.client.updateToolCall({ - toolCallId, - status: 'error', - content: { type: 'markdown', markdown: error.message }, - }); - + if (toolCallId) { + await this.client.updateToolCall({ + toolCallId, + status: 'error', + content: { type: 'markdown', markdown: error.message }, + }); + } return errorResponse(error); } } @@ -408,7 +406,7 @@ class GeminiAgent implements Agent { `Path ${pathName} not found directly, attempting glob search.`, ); try { - const globResult = await globTool.execute( + const globResult = await globTool.buildAndExecute( { pattern: `**/*${pathName}*`, path: this.config.getTargetDir(), @@ -530,12 +528,15 @@ class GeminiAgent implements Agent { respectGitIgnore, // Use configuration setting }; - const toolCall = await this.client.pushToolCall({ - icon: readManyFilesTool.icon, - label: readManyFilesTool.getDescription(toolArgs), - }); + let toolCallId: number | undefined = undefined; try { - const result = await readManyFilesTool.execute(toolArgs, abortSignal); + const invocation = readManyFilesTool.build(toolArgs); + const toolCall = await this.client.pushToolCall({ + icon: readManyFilesTool.icon, + label: invocation.getDescription(), + }); + toolCallId = toolCall.id; + const result = await invocation.execute(abortSignal); const content = toToolCallContent(result) || { type: 'markdown', markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, @@ -578,14 +579,16 @@ class GeminiAgent implements Agent { return processedQueryParts; } catch (error: unknown) { - await this.client.updateToolCall({ - toolCallId: toolCall.id, - status: 'error', - content: { - type: 'markdown', - markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, - }, - }); + if (toolCallId) { + await this.client.updateToolCall({ + toolCallId, + status: 'error', + content: { + type: 'markdown', + markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, + }, + }); + } throw error; } } diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 165b7b30..cef2f811 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -8,6 +8,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { PartListUnion, PartUnion } from '@google/genai'; import { + AnyToolInvocation, Config, getErrorMessage, isNodeError, @@ -254,7 +255,7 @@ export async function handleAtCommand({ `Path ${pathName} not found directly, attempting glob search.`, ); try { - const globResult = await globTool.execute( + const globResult = await globTool.buildAndExecute( { pattern: `**/*${pathName}*`, path: dir, @@ -411,12 +412,14 @@ export async function handleAtCommand({ }; let toolCallDisplay: IndividualToolCallDisplay; + let invocation: AnyToolInvocation | undefined = undefined; try { - const result = await readManyFilesTool.execute(toolArgs, signal); + invocation = readManyFilesTool.build(toolArgs); + const result = await invocation.execute(signal); toolCallDisplay = { callId: `client-read-${userMessageTimestamp}`, name: readManyFilesTool.displayName, - description: readManyFilesTool.getDescription(toolArgs), + description: invocation.getDescription(), status: ToolCallStatus.Success, resultDisplay: result.returnDisplay || @@ -466,7 +469,9 @@ export async function handleAtCommand({ toolCallDisplay = { callId: `client-read-${userMessageTimestamp}`, name: readManyFilesTool.displayName, - description: readManyFilesTool.getDescription(toolArgs), + description: + invocation?.getDescription() ?? + 'Error attempting to execute tool to read files', status: ToolCallStatus.Error, resultDisplay: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, confirmationDetails: undefined, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 062c1687..dd2428bb 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -21,6 +21,7 @@ import { EditorType, AuthType, GeminiEventType as ServerGeminiEventType, + AnyToolInvocation, } from '@google/gemini-cli-core'; import { Part, PartListUnion } from '@google/genai'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -452,9 +453,13 @@ describe('useGeminiStream', () => { }, tool: { name: 'tool1', + displayName: 'tool1', description: 'desc1', - getDescription: vi.fn(), + build: vi.fn(), } as any, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, startTime: Date.now(), endTime: Date.now(), } as TrackedCompletedToolCall, @@ -469,9 +474,13 @@ describe('useGeminiStream', () => { responseSubmittedToGemini: false, tool: { name: 'tool2', + displayName: 'tool2', description: 'desc2', - getDescription: vi.fn(), + build: vi.fn(), } as any, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, startTime: Date.now(), liveOutput: '...', } as TrackedExecutingToolCall, @@ -506,6 +515,12 @@ describe('useGeminiStream', () => { status: 'success', responseSubmittedToGemini: false, response: { callId: 'call1', responseParts: toolCall1ResponseParts }, + tool: { + displayName: 'MockTool', + }, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, } as TrackedCompletedToolCall, { request: { @@ -584,6 +599,12 @@ describe('useGeminiStream', () => { status: 'cancelled', response: { callId: '1', responseParts: [{ text: 'cancelled' }] }, responseSubmittedToGemini: false, + tool: { + displayName: 'mock tool', + }, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, } as TrackedCancelledToolCall, ]; const client = new MockedGeminiClientClass(mockConfig); @@ -644,9 +665,13 @@ describe('useGeminiStream', () => { }, tool: { name: 'toolA', + displayName: 'toolA', description: 'descA', - getDescription: vi.fn(), + build: vi.fn(), } as any, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, status: 'cancelled', response: { callId: 'cancel-1', @@ -668,9 +693,13 @@ describe('useGeminiStream', () => { }, tool: { name: 'toolB', + displayName: 'toolB', description: 'descB', - getDescription: vi.fn(), + build: vi.fn(), } as any, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, status: 'cancelled', response: { callId: 'cancel-2', @@ -760,9 +789,13 @@ describe('useGeminiStream', () => { responseSubmittedToGemini: false, tool: { name: 'tool1', + displayName: 'tool1', description: 'desc', - getDescription: vi.fn(), + build: vi.fn(), } as any, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, startTime: Date.now(), } as TrackedExecutingToolCall, ]; @@ -980,8 +1013,13 @@ describe('useGeminiStream', () => { tool: { name: 'tool1', description: 'desc1', - getDescription: vi.fn(), + build: vi.fn().mockImplementation((_) => ({ + getDescription: () => `Mock description`, + })), } as any, + invocation: { + getDescription: () => `Mock description`, + }, startTime: Date.now(), liveOutput: '...', } as TrackedExecutingToolCall, @@ -1131,9 +1169,13 @@ describe('useGeminiStream', () => { }, tool: { name: 'save_memory', + displayName: 'save_memory', description: 'Saves memory', - getDescription: vi.fn(), + build: vi.fn(), } as any, + invocation: { + getDescription: () => `Mock description`, + } as unknown as AnyToolInvocation, }; // Capture the onComplete callback diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 01993650..c6b802fc 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -17,7 +17,6 @@ import { OutputUpdateHandler, AllToolCallsCompleteHandler, ToolCallsUpdateHandler, - Tool, ToolCall, Status as CoreStatus, EditorType, @@ -216,23 +215,20 @@ export function mapToDisplay( const toolDisplays = toolCalls.map( (trackedCall): IndividualToolCallDisplay => { - let displayName = trackedCall.request.name; - let description = ''; + let displayName: string; + let description: string; let renderOutputAsMarkdown = false; - const currentToolInstance = - 'tool' in trackedCall && trackedCall.tool - ? (trackedCall as { tool: Tool }).tool - : undefined; - - if (currentToolInstance) { - displayName = currentToolInstance.displayName; - description = currentToolInstance.getDescription( - trackedCall.request.args, - ); - renderOutputAsMarkdown = currentToolInstance.isOutputMarkdown; - } else if ('request' in trackedCall && 'args' in trackedCall.request) { + if (trackedCall.status === 'error') { + displayName = + trackedCall.tool === undefined + ? trackedCall.request.name + : trackedCall.tool.displayName; description = JSON.stringify(trackedCall.request.args); + } else { + displayName = trackedCall.tool.displayName; + description = trackedCall.invocation.getDescription(); + renderOutputAsMarkdown = trackedCall.tool.isOutputMarkdown; } const baseDisplayProperties: Omit< @@ -256,7 +252,6 @@ export function mapToDisplay( case 'error': return { ...baseDisplayProperties, - name: currentToolInstance?.displayName ?? trackedCall.request.name, status: mapCoreStatusToDisplayStatus(trackedCall.status), resultDisplay: trackedCall.response.resultDisplay, confirmationDetails: undefined, diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 5395d18a..ee5251d3 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -15,7 +15,6 @@ import { PartUnion, FunctionResponse } from '@google/genai'; import { Config, ToolCallRequestInfo, - Tool, ToolRegistry, ToolResult, ToolCallConfirmationDetails, @@ -25,6 +24,9 @@ import { Status as ToolCallStatusType, ApprovalMode, Icon, + BaseTool, + AnyDeclarativeTool, + AnyToolInvocation, } from '@google/gemini-cli-core'; import { HistoryItemWithoutId, @@ -53,46 +55,55 @@ const mockConfig = { getDebugMode: () => false, }; -const mockTool: Tool = { - name: 'mockTool', - displayName: 'Mock Tool', - description: 'A mock tool for testing', - icon: Icon.Hammer, - toolLocations: vi.fn(), - isOutputMarkdown: false, - canUpdateOutput: false, - schema: {}, - validateToolParams: vi.fn(), - execute: vi.fn(), - shouldConfirmExecute: vi.fn(), - getDescription: vi.fn((args) => `Description for ${JSON.stringify(args)}`), -}; +class MockTool extends BaseTool { + constructor( + name: string, + displayName: string, + canUpdateOutput = false, + shouldConfirm = false, + isOutputMarkdown = false, + ) { + super( + name, + displayName, + 'A mock tool for testing', + Icon.Hammer, + {}, + isOutputMarkdown, + canUpdateOutput, + ); + if (shouldConfirm) { + this.shouldConfirmExecute = vi.fn( + async (): Promise => ({ + type: 'edit', + title: 'Mock Tool Requires Confirmation', + onConfirm: mockOnUserConfirmForToolConfirmation, + fileName: 'mockToolRequiresConfirmation.ts', + fileDiff: 'Mock tool requires confirmation', + originalContent: 'Original content', + newContent: 'New content', + }), + ); + } + } -const mockToolWithLiveOutput: Tool = { - ...mockTool, - name: 'mockToolWithLiveOutput', - displayName: 'Mock Tool With Live Output', - canUpdateOutput: true, -}; + execute = vi.fn(); + shouldConfirmExecute = vi.fn(); +} +const mockTool = new MockTool('mockTool', 'Mock Tool'); +const mockToolWithLiveOutput = new MockTool( + 'mockToolWithLiveOutput', + 'Mock Tool With Live Output', + true, +); let mockOnUserConfirmForToolConfirmation: Mock; - -const mockToolRequiresConfirmation: Tool = { - ...mockTool, - name: 'mockToolRequiresConfirmation', - displayName: 'Mock Tool Requires Confirmation', - shouldConfirmExecute: vi.fn( - async (): Promise => ({ - type: 'edit', - title: 'Mock Tool Requires Confirmation', - onConfirm: mockOnUserConfirmForToolConfirmation, - fileName: 'mockToolRequiresConfirmation.ts', - fileDiff: 'Mock tool requires confirmation', - originalContent: 'Original content', - newContent: 'New content', - }), - ), -}; +const mockToolRequiresConfirmation = new MockTool( + 'mockToolRequiresConfirmation', + 'Mock Tool Requires Confirmation', + false, + true, +); describe('useReactToolScheduler in YOLO Mode', () => { let onComplete: Mock; @@ -646,28 +657,21 @@ describe('useReactToolScheduler', () => { }); it('should schedule and execute multiple tool calls', async () => { - const tool1 = { - ...mockTool, - name: 'tool1', - displayName: 'Tool 1', - execute: vi.fn().mockResolvedValue({ - llmContent: 'Output 1', - returnDisplay: 'Display 1', - summary: 'Summary 1', - } as ToolResult), - shouldConfirmExecute: vi.fn().mockResolvedValue(null), - }; - const tool2 = { - ...mockTool, - name: 'tool2', - displayName: 'Tool 2', - execute: vi.fn().mockResolvedValue({ - llmContent: 'Output 2', - returnDisplay: 'Display 2', - summary: 'Summary 2', - } as ToolResult), - shouldConfirmExecute: vi.fn().mockResolvedValue(null), - }; + const tool1 = new MockTool('tool1', 'Tool 1'); + tool1.execute.mockResolvedValue({ + llmContent: 'Output 1', + returnDisplay: 'Display 1', + summary: 'Summary 1', + } as ToolResult); + tool1.shouldConfirmExecute.mockResolvedValue(null); + + const tool2 = new MockTool('tool2', 'Tool 2'); + tool2.execute.mockResolvedValue({ + llmContent: 'Output 2', + returnDisplay: 'Display 2', + summary: 'Summary 2', + } as ToolResult); + tool2.shouldConfirmExecute.mockResolvedValue(null); mockToolRegistry.getTool.mockImplementation((name) => { if (name === 'tool1') return tool1; @@ -805,20 +809,7 @@ describe('mapToDisplay', () => { args: { foo: 'bar' }, }; - const baseTool: Tool = { - name: 'testTool', - displayName: 'Test Tool Display', - description: 'Test Description', - isOutputMarkdown: false, - canUpdateOutput: false, - schema: {}, - icon: Icon.Hammer, - toolLocations: vi.fn(), - validateToolParams: vi.fn(), - execute: vi.fn(), - shouldConfirmExecute: vi.fn(), - getDescription: vi.fn((args) => `Desc: ${JSON.stringify(args)}`), - }; + const baseTool = new MockTool('testTool', 'Test Tool Display'); const baseResponse: ToolCallResponseInfo = { callId: 'testCallId', @@ -840,13 +831,15 @@ describe('mapToDisplay', () => { // This helps ensure that tool and confirmationDetails are only accessed when they are expected to exist. type MapToDisplayExtraProps = | { - tool?: Tool; + tool?: AnyDeclarativeTool; + invocation?: AnyToolInvocation; liveOutput?: string; response?: ToolCallResponseInfo; confirmationDetails?: ToolCallConfirmationDetails; } | { - tool: Tool; + tool: AnyDeclarativeTool; + invocation?: AnyToolInvocation; response?: ToolCallResponseInfo; confirmationDetails?: ToolCallConfirmationDetails; } @@ -857,10 +850,12 @@ describe('mapToDisplay', () => { } | { confirmationDetails: ToolCallConfirmationDetails; - tool?: Tool; + tool?: AnyDeclarativeTool; + invocation?: AnyToolInvocation; response?: ToolCallResponseInfo; }; + const baseInvocation = baseTool.build(baseRequest.args); const testCases: Array<{ name: string; status: ToolCallStatusType; @@ -873,7 +868,7 @@ describe('mapToDisplay', () => { { name: 'validating', status: 'validating', - extraProps: { tool: baseTool }, + extraProps: { tool: baseTool, invocation: baseInvocation }, expectedStatus: ToolCallStatus.Executing, expectedName: baseTool.displayName, expectedDescription: baseTool.getDescription(baseRequest.args), @@ -883,6 +878,7 @@ describe('mapToDisplay', () => { status: 'awaiting_approval', extraProps: { tool: baseTool, + invocation: baseInvocation, confirmationDetails: { onConfirm: vi.fn(), type: 'edit', @@ -903,7 +899,7 @@ describe('mapToDisplay', () => { { name: 'scheduled', status: 'scheduled', - extraProps: { tool: baseTool }, + extraProps: { tool: baseTool, invocation: baseInvocation }, expectedStatus: ToolCallStatus.Pending, expectedName: baseTool.displayName, expectedDescription: baseTool.getDescription(baseRequest.args), @@ -911,7 +907,7 @@ describe('mapToDisplay', () => { { name: 'executing no live output', status: 'executing', - extraProps: { tool: baseTool }, + extraProps: { tool: baseTool, invocation: baseInvocation }, expectedStatus: ToolCallStatus.Executing, expectedName: baseTool.displayName, expectedDescription: baseTool.getDescription(baseRequest.args), @@ -919,7 +915,11 @@ describe('mapToDisplay', () => { { name: 'executing with live output', status: 'executing', - extraProps: { tool: baseTool, liveOutput: 'Live test output' }, + extraProps: { + tool: baseTool, + invocation: baseInvocation, + liveOutput: 'Live test output', + }, expectedStatus: ToolCallStatus.Executing, expectedResultDisplay: 'Live test output', expectedName: baseTool.displayName, @@ -928,7 +928,11 @@ describe('mapToDisplay', () => { { name: 'success', status: 'success', - extraProps: { tool: baseTool, response: baseResponse }, + extraProps: { + tool: baseTool, + invocation: baseInvocation, + response: baseResponse, + }, expectedStatus: ToolCallStatus.Success, expectedResultDisplay: baseResponse.resultDisplay as any, expectedName: baseTool.displayName, @@ -970,6 +974,7 @@ describe('mapToDisplay', () => { status: 'cancelled', extraProps: { tool: baseTool, + invocation: baseInvocation, response: { ...baseResponse, resultDisplay: 'Cancelled display', @@ -1030,12 +1035,21 @@ describe('mapToDisplay', () => { request: { ...baseRequest, callId: 'call1' }, status: 'success', tool: baseTool, + invocation: baseTool.build(baseRequest.args), response: { ...baseResponse, callId: 'call1' }, } as ToolCall; + const toolForCall2 = new MockTool( + baseTool.name, + baseTool.displayName, + false, + false, + true, + ); const toolCall2: ToolCall = { request: { ...baseRequest, callId: 'call2' }, status: 'executing', - tool: { ...baseTool, isOutputMarkdown: true }, + tool: toolForCall2, + invocation: toolForCall2.build(baseRequest.args), liveOutput: 'markdown output', } as ToolCall; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 3b6b57f9..f8b9a7de 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -24,7 +24,6 @@ import { import { Config } from '../config/config.js'; import { UserTierId } from '../code_assist/types.js'; import { getCoreSystemPrompt, getCompressionPrompt } from './prompts.js'; -import { ReadManyFilesTool } from '../tools/read-many-files.js'; import { getResponseText } from '../utils/generateContentResponseUtilities.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { reportError } from '../utils/errorReporting.js'; @@ -252,18 +251,15 @@ export class GeminiClient { // Add full file context if the flag is set if (this.config.getFullContext()) { try { - const readManyFilesTool = toolRegistry.getTool( - 'read_many_files', - ) as ReadManyFilesTool; + const readManyFilesTool = toolRegistry.getTool('read_many_files'); if (readManyFilesTool) { + const invocation = readManyFilesTool.build({ + paths: ['**/*'], // Read everything recursively + useDefaultExcludes: true, // Use default excludes + }); + // Read all files in the target directory - const result = await readManyFilesTool.execute( - { - paths: ['**/*'], // Read everything recursively - useDefaultExcludes: true, // Use default excludes - }, - AbortSignal.timeout(30000), - ); + const result = await invocation.execute(AbortSignal.timeout(30000)); if (result.llmContent) { initialParts.push({ text: `\n--- Full File Context ---\n${result.llmContent}`, diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 4d786d00..a65443f8 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -24,44 +24,15 @@ import { } from '../index.js'; import { Part, PartListUnion } from '@google/genai'; -import { ModifiableTool, ModifyContext } from '../tools/modifiable-tool.js'; - -class MockTool extends BaseTool, ToolResult> { - shouldConfirm = false; - executeFn = vi.fn(); - - constructor(name = 'mockTool') { - super(name, name, 'A mock tool', Icon.Hammer, {}); - } - - async shouldConfirmExecute( - _params: Record, - _abortSignal: AbortSignal, - ): Promise { - if (this.shouldConfirm) { - return { - type: 'exec', - title: 'Confirm Mock Tool', - command: 'do_thing', - rootCommand: 'do_thing', - onConfirm: async () => {}, - }; - } - return false; - } - - async execute( - params: Record, - _abortSignal: AbortSignal, - ): Promise { - this.executeFn(params); - return { llmContent: 'Tool executed', returnDisplay: 'Tool executed' }; - } -} +import { + ModifiableDeclarativeTool, + ModifyContext, +} from '../tools/modifiable-tool.js'; +import { MockTool } from '../test-utils/tools.js'; class MockModifiableTool extends MockTool - implements ModifiableTool> + implements ModifiableDeclarativeTool> { constructor(name = 'mockModifiableTool') { super(name); @@ -83,10 +54,7 @@ class MockModifiableTool }; } - async shouldConfirmExecute( - _params: Record, - _abortSignal: AbortSignal, - ): Promise { + async shouldConfirmExecute(): Promise { if (this.shouldConfirm) { return { type: 'edit', @@ -107,14 +75,15 @@ describe('CoreToolScheduler', () => { it('should cancel a tool call if the signal is aborted before confirmation', async () => { const mockTool = new MockTool(); mockTool.shouldConfirm = true; + const declarativeTool = mockTool; const toolRegistry = { - getTool: () => mockTool, + getTool: () => declarativeTool, getFunctionDeclarations: () => [], tools: new Map(), discovery: {} as any, registerTool: () => {}, - getToolByName: () => mockTool, - getToolByDisplayName: () => mockTool, + getToolByName: () => declarativeTool, + getToolByDisplayName: () => declarativeTool, getTools: () => [], discoverTools: async () => {}, getAllTools: () => [], @@ -177,14 +146,15 @@ describe('CoreToolScheduler', () => { describe('CoreToolScheduler with payload', () => { it('should update args and diff and execute tool when payload is provided', async () => { const mockTool = new MockModifiableTool(); + const declarativeTool = mockTool; const toolRegistry = { - getTool: () => mockTool, + getTool: () => declarativeTool, getFunctionDeclarations: () => [], tools: new Map(), discovery: {} as any, registerTool: () => {}, - getToolByName: () => mockTool, - getToolByDisplayName: () => mockTool, + getToolByName: () => declarativeTool, + getToolByDisplayName: () => declarativeTool, getTools: () => [], discoverTools: async () => {}, getAllTools: () => [], @@ -221,10 +191,7 @@ describe('CoreToolScheduler with payload', () => { await scheduler.schedule([request], abortController.signal); - const confirmationDetails = await mockTool.shouldConfirmExecute( - {}, - abortController.signal, - ); + const confirmationDetails = await mockTool.shouldConfirmExecute(); if (confirmationDetails) { const payload: ToolConfirmationPayload = { newContent: 'final version' }; @@ -456,14 +423,15 @@ describe('CoreToolScheduler edit cancellation', () => { } const mockEditTool = new MockEditTool(); + const declarativeTool = mockEditTool; const toolRegistry = { - getTool: () => mockEditTool, + getTool: () => declarativeTool, getFunctionDeclarations: () => [], tools: new Map(), discovery: {} as any, registerTool: () => {}, - getToolByName: () => mockEditTool, - getToolByDisplayName: () => mockEditTool, + getToolByName: () => declarativeTool, + getToolByDisplayName: () => declarativeTool, getTools: () => [], discoverTools: async () => {}, getAllTools: () => [], @@ -541,18 +509,23 @@ describe('CoreToolScheduler YOLO mode', () => { it('should execute tool requiring confirmation directly without waiting', async () => { // Arrange const mockTool = new MockTool(); + mockTool.executeFn.mockReturnValue({ + llmContent: 'Tool executed', + returnDisplay: 'Tool executed', + }); // This tool would normally require confirmation. mockTool.shouldConfirm = true; + const declarativeTool = mockTool; const toolRegistry = { - getTool: () => mockTool, - getToolByName: () => mockTool, + getTool: () => declarativeTool, + getToolByName: () => declarativeTool, // Other properties are not needed for this test but are included for type consistency. getFunctionDeclarations: () => [], tools: new Map(), discovery: {} as any, registerTool: () => {}, - getToolByDisplayName: () => mockTool, + getToolByDisplayName: () => declarativeTool, getTools: () => [], discoverTools: async () => {}, getAllTools: () => [], diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 9b999b6b..6f098ae3 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -8,7 +8,6 @@ import { ToolCallRequestInfo, ToolCallResponseInfo, ToolConfirmationOutcome, - Tool, ToolCallConfirmationDetails, ToolResult, ToolResultDisplay, @@ -20,11 +19,13 @@ import { ToolCallEvent, ToolConfirmationPayload, ToolErrorType, + AnyDeclarativeTool, + AnyToolInvocation, } from '../index.js'; import { Part, PartListUnion } from '@google/genai'; import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js'; import { - isModifiableTool, + isModifiableDeclarativeTool, ModifyContext, modifyWithEditor, } from '../tools/modifiable-tool.js'; @@ -33,7 +34,8 @@ import * as Diff from 'diff'; export type ValidatingToolCall = { status: 'validating'; request: ToolCallRequestInfo; - tool: Tool; + tool: AnyDeclarativeTool; + invocation: AnyToolInvocation; startTime?: number; outcome?: ToolConfirmationOutcome; }; @@ -41,7 +43,8 @@ export type ValidatingToolCall = { export type ScheduledToolCall = { status: 'scheduled'; request: ToolCallRequestInfo; - tool: Tool; + tool: AnyDeclarativeTool; + invocation: AnyToolInvocation; startTime?: number; outcome?: ToolConfirmationOutcome; }; @@ -50,6 +53,7 @@ export type ErroredToolCall = { status: 'error'; request: ToolCallRequestInfo; response: ToolCallResponseInfo; + tool?: AnyDeclarativeTool; durationMs?: number; outcome?: ToolConfirmationOutcome; }; @@ -57,8 +61,9 @@ export type ErroredToolCall = { export type SuccessfulToolCall = { status: 'success'; request: ToolCallRequestInfo; - tool: Tool; + tool: AnyDeclarativeTool; response: ToolCallResponseInfo; + invocation: AnyToolInvocation; durationMs?: number; outcome?: ToolConfirmationOutcome; }; @@ -66,7 +71,8 @@ export type SuccessfulToolCall = { export type ExecutingToolCall = { status: 'executing'; request: ToolCallRequestInfo; - tool: Tool; + tool: AnyDeclarativeTool; + invocation: AnyToolInvocation; liveOutput?: string; startTime?: number; outcome?: ToolConfirmationOutcome; @@ -76,7 +82,8 @@ export type CancelledToolCall = { status: 'cancelled'; request: ToolCallRequestInfo; response: ToolCallResponseInfo; - tool: Tool; + tool: AnyDeclarativeTool; + invocation: AnyToolInvocation; durationMs?: number; outcome?: ToolConfirmationOutcome; }; @@ -84,7 +91,8 @@ export type CancelledToolCall = { export type WaitingToolCall = { status: 'awaiting_approval'; request: ToolCallRequestInfo; - tool: Tool; + tool: AnyDeclarativeTool; + invocation: AnyToolInvocation; confirmationDetails: ToolCallConfirmationDetails; startTime?: number; outcome?: ToolConfirmationOutcome; @@ -289,6 +297,7 @@ export class CoreToolScheduler { // currentCall is a non-terminal state here and should have startTime and tool. const existingStartTime = currentCall.startTime; const toolInstance = currentCall.tool; + const invocation = currentCall.invocation; const outcome = currentCall.outcome; @@ -300,6 +309,7 @@ export class CoreToolScheduler { return { request: currentCall.request, tool: toolInstance, + invocation, status: 'success', response: auxiliaryData as ToolCallResponseInfo, durationMs, @@ -313,6 +323,7 @@ export class CoreToolScheduler { return { request: currentCall.request, status: 'error', + tool: toolInstance, response: auxiliaryData as ToolCallResponseInfo, durationMs, outcome, @@ -326,6 +337,7 @@ export class CoreToolScheduler { confirmationDetails: auxiliaryData as ToolCallConfirmationDetails, startTime: existingStartTime, outcome, + invocation, } as WaitingToolCall; case 'scheduled': return { @@ -334,6 +346,7 @@ export class CoreToolScheduler { status: 'scheduled', startTime: existingStartTime, outcome, + invocation, } as ScheduledToolCall; case 'cancelled': { const durationMs = existingStartTime @@ -358,6 +371,7 @@ export class CoreToolScheduler { return { request: currentCall.request, tool: toolInstance, + invocation, status: 'cancelled', response: { callId: currentCall.request.callId, @@ -385,6 +399,7 @@ export class CoreToolScheduler { status: 'validating', startTime: existingStartTime, outcome, + invocation, } as ValidatingToolCall; case 'executing': return { @@ -393,6 +408,7 @@ export class CoreToolScheduler { status: 'executing', startTime: existingStartTime, outcome, + invocation, } as ExecutingToolCall; default: { const exhaustiveCheck: never = newStatus; @@ -406,10 +422,34 @@ export class CoreToolScheduler { private setArgsInternal(targetCallId: string, args: unknown): void { this.toolCalls = this.toolCalls.map((call) => { - if (call.request.callId !== targetCallId) return call; + // We should never be asked to set args on an ErroredToolCall, but + // we guard for the case anyways. + if (call.request.callId !== targetCallId || call.status === 'error') { + return call; + } + + const invocationOrError = this.buildInvocation( + call.tool, + args as Record, + ); + if (invocationOrError instanceof Error) { + const response = createErrorResponse( + call.request, + invocationOrError, + ToolErrorType.INVALID_TOOL_PARAMS, + ); + return { + request: { ...call.request, args: args as Record }, + status: 'error', + tool: call.tool, + response, + } as ErroredToolCall; + } + return { ...call, request: { ...call.request, args: args as Record }, + invocation: invocationOrError, }; }); } @@ -421,6 +461,20 @@ export class CoreToolScheduler { ); } + private buildInvocation( + tool: AnyDeclarativeTool, + args: object, + ): AnyToolInvocation | Error { + try { + return tool.build(args); + } catch (e) { + if (e instanceof Error) { + return e; + } + return new Error(String(e)); + } + } + async schedule( request: ToolCallRequestInfo | ToolCallRequestInfo[], signal: AbortSignal, @@ -448,10 +502,30 @@ export class CoreToolScheduler { durationMs: 0, }; } + + const invocationOrError = this.buildInvocation( + toolInstance, + reqInfo.args, + ); + if (invocationOrError instanceof Error) { + return { + status: 'error', + request: reqInfo, + tool: toolInstance, + response: createErrorResponse( + reqInfo, + invocationOrError, + ToolErrorType.INVALID_TOOL_PARAMS, + ), + durationMs: 0, + }; + } + return { status: 'validating', request: reqInfo, tool: toolInstance, + invocation: invocationOrError, startTime: Date.now(), }; }, @@ -465,7 +539,8 @@ export class CoreToolScheduler { continue; } - const { request: reqInfo, tool: toolInstance } = toolCall; + const { request: reqInfo, invocation } = toolCall; + try { if (this.config.getApprovalMode() === ApprovalMode.YOLO) { this.setToolCallOutcome( @@ -474,10 +549,8 @@ export class CoreToolScheduler { ); this.setStatusInternal(reqInfo.callId, 'scheduled'); } else { - const confirmationDetails = await toolInstance.shouldConfirmExecute( - reqInfo.args, - signal, - ); + const confirmationDetails = + await invocation.shouldConfirmExecute(signal); if (confirmationDetails) { // Allow IDE to resolve confirmation @@ -573,7 +646,7 @@ export class CoreToolScheduler { ); } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { const waitingToolCall = toolCall as WaitingToolCall; - if (isModifiableTool(waitingToolCall.tool)) { + if (isModifiableDeclarativeTool(waitingToolCall.tool)) { const modifyContext = waitingToolCall.tool.getModifyContext(signal); const editorType = this.getPreferredEditor(); if (!editorType) { @@ -628,7 +701,7 @@ export class CoreToolScheduler { ): Promise { if ( toolCall.confirmationDetails.type !== 'edit' || - !isModifiableTool(toolCall.tool) + !isModifiableDeclarativeTool(toolCall.tool) ) { return; } @@ -677,6 +750,7 @@ export class CoreToolScheduler { const scheduledCall = toolCall; const { callId, name: toolName } = scheduledCall.request; + const invocation = scheduledCall.invocation; this.setStatusInternal(callId, 'executing'); const liveOutputCallback = @@ -694,8 +768,8 @@ export class CoreToolScheduler { } : undefined; - scheduledCall.tool - .execute(scheduledCall.request.args, signal, liveOutputCallback) + invocation + .execute(signal, liveOutputCallback) .then(async (toolResult: ToolResult) => { if (signal.aborted) { this.setStatusInternal( diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 1bbb9209..b0ed7107 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -10,12 +10,10 @@ import { ToolRegistry, ToolCallRequestInfo, ToolResult, - Tool, - ToolCallConfirmationDetails, Config, - Icon, } from '../index.js'; -import { Part, Type } from '@google/genai'; +import { Part } from '@google/genai'; +import { MockTool } from '../test-utils/tools.js'; const mockConfig = { getSessionId: () => 'test-session-id', @@ -25,36 +23,11 @@ const mockConfig = { describe('executeToolCall', () => { let mockToolRegistry: ToolRegistry; - let mockTool: Tool; + let mockTool: MockTool; let abortController: AbortController; beforeEach(() => { - mockTool = { - name: 'testTool', - displayName: 'Test Tool', - description: 'A tool for testing', - icon: Icon.Hammer, - schema: { - name: 'testTool', - description: 'A tool for testing', - parameters: { - type: Type.OBJECT, - properties: { - param1: { type: Type.STRING }, - }, - required: ['param1'], - }, - }, - execute: vi.fn(), - validateToolParams: vi.fn(() => null), - shouldConfirmExecute: vi.fn(() => - Promise.resolve(false as false | ToolCallConfirmationDetails), - ), - isOutputMarkdown: false, - canUpdateOutput: false, - getDescription: vi.fn(), - toolLocations: vi.fn(() => []), - }; + mockTool = new MockTool(); mockToolRegistry = { getTool: vi.fn(), @@ -77,7 +50,7 @@ describe('executeToolCall', () => { returnDisplay: 'Success!', }; vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); - vi.mocked(mockTool.execute).mockResolvedValue(toolResult); + vi.spyOn(mockTool, 'buildAndExecute').mockResolvedValue(toolResult); const response = await executeToolCall( mockConfig, @@ -87,7 +60,7 @@ describe('executeToolCall', () => { ); expect(mockToolRegistry.getTool).toHaveBeenCalledWith('testTool'); - expect(mockTool.execute).toHaveBeenCalledWith( + expect(mockTool.buildAndExecute).toHaveBeenCalledWith( request.args, abortController.signal, ); @@ -149,7 +122,7 @@ describe('executeToolCall', () => { }; const executionError = new Error('Tool execution failed'); vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); - vi.mocked(mockTool.execute).mockRejectedValue(executionError); + vi.spyOn(mockTool, 'buildAndExecute').mockRejectedValue(executionError); const response = await executeToolCall( mockConfig, @@ -183,25 +156,27 @@ describe('executeToolCall', () => { const cancellationError = new Error('Operation cancelled'); vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); - vi.mocked(mockTool.execute).mockImplementation(async (_args, signal) => { - if (signal?.aborted) { - return Promise.reject(cancellationError); - } - return new Promise((_resolve, reject) => { - signal?.addEventListener('abort', () => { - reject(cancellationError); + vi.spyOn(mockTool, 'buildAndExecute').mockImplementation( + async (_args, signal) => { + if (signal?.aborted) { + return Promise.reject(cancellationError); + } + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(cancellationError); + }); + // Simulate work that might happen if not aborted immediately + const timeoutId = setTimeout( + () => + reject( + new Error('Should have been cancelled if not aborted prior'), + ), + 100, + ); + signal?.addEventListener('abort', () => clearTimeout(timeoutId)); }); - // Simulate work that might happen if not aborted immediately - const timeoutId = setTimeout( - () => - reject( - new Error('Should have been cancelled if not aborted prior'), - ), - 100, - ); - signal?.addEventListener('abort', () => clearTimeout(timeoutId)); - }); - }); + }, + ); abortController.abort(); // Abort before calling const response = await executeToolCall( @@ -232,7 +207,7 @@ describe('executeToolCall', () => { returnDisplay: 'Image processed', }; vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool); - vi.mocked(mockTool.execute).mockResolvedValue(toolResult); + vi.spyOn(mockTool, 'buildAndExecute').mockResolvedValue(toolResult); const response = await executeToolCall( mockConfig, diff --git a/packages/core/src/core/nonInteractiveToolExecutor.ts b/packages/core/src/core/nonInteractiveToolExecutor.ts index ed235cd3..43061f83 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.ts @@ -65,7 +65,7 @@ export async function executeToolCall( try { // Directly execute without confirmation or live output handling const effectiveAbortSignal = abortSignal ?? new AbortController().signal; - const toolResult: ToolResult = await tool.execute( + const toolResult: ToolResult = await tool.buildAndExecute( toolCallRequest.args, effectiveAbortSignal, // No live output callback for non-interactive mode diff --git a/packages/core/src/telemetry/loggers.test.circular.ts b/packages/core/src/telemetry/loggers.test.circular.ts index 80444a0d..3cf85e46 100644 --- a/packages/core/src/telemetry/loggers.test.circular.ts +++ b/packages/core/src/telemetry/loggers.test.circular.ts @@ -14,7 +14,7 @@ import { ToolCallEvent } from './types.js'; import { Config } from '../config/config.js'; import { CompletedToolCall } from '../core/coreToolScheduler.js'; import { ToolCallRequestInfo, ToolCallResponseInfo } from '../core/turn.js'; -import { Tool } from '../tools/tools.js'; +import { MockTool } from '../test-utils/tools.js'; describe('Circular Reference Handling', () => { it('should handle circular references in tool function arguments', () => { @@ -56,11 +56,13 @@ describe('Circular Reference Handling', () => { errorType: undefined, }; + const tool = new MockTool('mock-tool'); const mockCompletedToolCall: CompletedToolCall = { status: 'success', request: mockRequest, response: mockResponse, - tool: {} as Tool, + tool, + invocation: tool.build({}), durationMs: 100, }; @@ -104,11 +106,13 @@ describe('Circular Reference Handling', () => { errorType: undefined, }; + const tool = new MockTool('mock-tool'); const mockCompletedToolCall: CompletedToolCall = { status: 'success', request: mockRequest, response: mockResponse, - tool: {} as Tool, + tool, + invocation: tool.build({}), durationMs: 100, }; diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 3d8116cc..14de83a9 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -5,6 +5,7 @@ */ import { + AnyToolInvocation, AuthType, CompletedToolCall, ContentGeneratorConfig, @@ -432,6 +433,7 @@ describe('loggers', () => { }); it('should log a tool call with all fields', () => { + const tool = new EditTool(mockConfig); const call: CompletedToolCall = { status: 'success', request: { @@ -451,7 +453,8 @@ describe('loggers', () => { error: undefined, errorType: undefined, }, - tool: new EditTool(mockConfig), + tool, + invocation: {} as AnyToolInvocation, durationMs: 100, outcome: ToolConfirmationOutcome.ProceedOnce, }; @@ -581,6 +584,7 @@ describe('loggers', () => { }, outcome: ToolConfirmationOutcome.ModifyWithEditor, tool: new EditTool(mockConfig), + invocation: {} as AnyToolInvocation, durationMs: 100, }; const event = new ToolCallEvent(call); @@ -645,6 +649,7 @@ describe('loggers', () => { errorType: undefined, }, tool: new EditTool(mockConfig), + invocation: {} as AnyToolInvocation, durationMs: 100, }; const event = new ToolCallEvent(call); diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index 221804d2..ac9727f1 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -23,7 +23,8 @@ import { SuccessfulToolCall, } from '../core/coreToolScheduler.js'; import { ToolErrorType } from '../tools/tool-error.js'; -import { Tool, ToolConfirmationOutcome } from '../tools/tools.js'; +import { ToolConfirmationOutcome } from '../tools/tools.js'; +import { MockTool } from '../test-utils/tools.js'; const createFakeCompletedToolCall = ( name: string, @@ -39,12 +40,14 @@ const createFakeCompletedToolCall = ( isClientInitiated: false, prompt_id: 'prompt-id-1', }; + const tool = new MockTool(name); if (success) { return { status: 'success', request, - tool: { name } as Tool, // Mock tool + tool, + invocation: tool.build({}), response: { callId: request.callId, responseParts: { @@ -65,6 +68,7 @@ const createFakeCompletedToolCall = ( return { status: 'error', request, + tool, response: { callId: request.callId, responseParts: { diff --git a/packages/core/src/test-utils/tools.ts b/packages/core/src/test-utils/tools.ts new file mode 100644 index 00000000..b168db9c --- /dev/null +++ b/packages/core/src/test-utils/tools.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import { + BaseTool, + Icon, + ToolCallConfirmationDetails, + ToolResult, +} from '../tools/tools.js'; +import { Schema, Type } from '@google/genai'; + +/** + * A highly configurable mock tool for testing purposes. + */ +export class MockTool extends BaseTool<{ [key: string]: unknown }, ToolResult> { + executeFn = vi.fn(); + shouldConfirm = false; + + constructor( + name = 'mock-tool', + displayName?: string, + description = 'A mock tool for testing.', + params: Schema = { + type: Type.OBJECT, + properties: { param: { type: Type.STRING } }, + }, + ) { + super(name, displayName ?? name, description, Icon.Hammer, params); + } + + async execute( + params: { [key: string]: unknown }, + _abortSignal: AbortSignal, + ): Promise { + const result = this.executeFn(params); + return ( + result ?? { + llmContent: `Tool ${this.name} executed successfully.`, + returnDisplay: `Tool ${this.name} executed successfully.`, + } + ); + } + + async shouldConfirmExecute( + _params: { [key: string]: unknown }, + _abortSignal: AbortSignal, + ): Promise { + if (this.shouldConfirm) { + return { + type: 'exec' as const, + title: `Confirm ${this.displayName}`, + command: this.name, + rootCommand: this.name, + onConfirm: async () => {}, + }; + } + return false; + } +} diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 0d129e42..853ad4c1 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -26,7 +26,7 @@ import { Config, ApprovalMode } from '../config/config.js'; import { ensureCorrectEdit } from '../utils/editCorrector.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { ReadFileTool } from './read-file.js'; -import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; +import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js'; /** * Parameters for the Edit tool @@ -72,7 +72,7 @@ interface CalculatedEdit { */ export class EditTool extends BaseTool - implements ModifiableTool + implements ModifiableDeclarativeTool { static readonly Name = 'replace'; diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index 847ea5cf..f3bf315b 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -18,7 +18,7 @@ import { homedir } from 'os'; import * as Diff from 'diff'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { tildeifyPath } from '../utils/paths.js'; -import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; +import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js'; const memoryToolSchemaData: FunctionDeclaration = { name: 'save_memory', @@ -112,7 +112,7 @@ function ensureNewlineSeparation(currentContent: string): string { export class MemoryTool extends BaseTool - implements ModifiableTool + implements ModifiableDeclarativeTool { private static readonly allowlist: Set = new Set(); diff --git a/packages/core/src/tools/modifiable-tool.test.ts b/packages/core/src/tools/modifiable-tool.test.ts index eb7e8dbf..dc68640a 100644 --- a/packages/core/src/tools/modifiable-tool.test.ts +++ b/packages/core/src/tools/modifiable-tool.test.ts @@ -8,8 +8,8 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { modifyWithEditor, ModifyContext, - ModifiableTool, - isModifiableTool, + ModifiableDeclarativeTool, + isModifiableDeclarativeTool, } from './modifiable-tool.js'; import { EditorType } from '../utils/editor.js'; import fs from 'fs'; @@ -338,16 +338,16 @@ describe('isModifiableTool', () => { const mockTool = { name: 'test-tool', getModifyContext: vi.fn(), - } as unknown as ModifiableTool; + } as unknown as ModifiableDeclarativeTool; - expect(isModifiableTool(mockTool)).toBe(true); + expect(isModifiableDeclarativeTool(mockTool)).toBe(true); }); it('should return false for objects without getModifyContext method', () => { const mockTool = { name: 'test-tool', - } as unknown as ModifiableTool; + } as unknown as ModifiableDeclarativeTool; - expect(isModifiableTool(mockTool)).toBe(false); + expect(isModifiableDeclarativeTool(mockTool)).toBe(false); }); }); diff --git a/packages/core/src/tools/modifiable-tool.ts b/packages/core/src/tools/modifiable-tool.ts index 42de3eb6..25a2906b 100644 --- a/packages/core/src/tools/modifiable-tool.ts +++ b/packages/core/src/tools/modifiable-tool.ts @@ -11,13 +11,14 @@ import fs from 'fs'; import * as Diff from 'diff'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { isNodeError } from '../utils/errors.js'; -import { Tool } from './tools.js'; +import { AnyDeclarativeTool, DeclarativeTool, ToolResult } from './tools.js'; /** - * A tool that supports a modify operation. + * A declarative tool that supports a modify operation. */ -export interface ModifiableTool extends Tool { - getModifyContext(abortSignal: AbortSignal): ModifyContext; +export interface ModifiableDeclarativeTool + extends DeclarativeTool { + getModifyContext(abortSignal: AbortSignal): ModifyContext; } export interface ModifyContext { @@ -39,9 +40,12 @@ export interface ModifyResult { updatedDiff: string; } -export function isModifiableTool( - tool: Tool, -): tool is ModifiableTool { +/** + * Type guard to check if a declarative tool is modifiable. + */ +export function isModifiableDeclarativeTool( + tool: AnyDeclarativeTool, +): tool is ModifiableDeclarativeTool { return 'getModifyContext' in tool; } diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index fa1e458c..bb9317fd 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -13,6 +13,7 @@ import fsp from 'fs/promises'; import { Config } from '../config/config.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; +import { ToolInvocation, ToolResult } from './tools.js'; describe('ReadFileTool', () => { let tempRootDir: string; @@ -40,57 +41,62 @@ describe('ReadFileTool', () => { } }); - describe('validateToolParams', () => { - it('should return null for valid params (absolute path within root)', () => { + describe('build', () => { + it('should return an invocation for valid params (absolute path within root)', () => { const params: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), }; - expect(tool.validateToolParams(params)).toBeNull(); + const result = tool.build(params); + expect(result).not.toBeTypeOf('string'); + expect(typeof result).toBe('object'); + expect( + (result as ToolInvocation).params, + ).toEqual(params); }); - it('should return null for valid params with offset and limit', () => { + it('should return an invocation for valid params with offset and limit', () => { const params: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), offset: 0, limit: 10, }; - expect(tool.validateToolParams(params)).toBeNull(); + const result = tool.build(params); + expect(result).not.toBeTypeOf('string'); }); - it('should return error for relative path', () => { + it('should throw error for relative path', () => { const params: ReadFileToolParams = { absolute_path: 'test.txt' }; - expect(tool.validateToolParams(params)).toBe( + expect(() => tool.build(params)).toThrow( `File path must be absolute, but was relative: test.txt. You must provide an absolute path.`, ); }); - it('should return error for path outside root', () => { + it('should throw error for path outside root', () => { const outsidePath = path.resolve(os.tmpdir(), 'outside-root.txt'); const params: ReadFileToolParams = { absolute_path: outsidePath }; - const error = tool.validateToolParams(params); - expect(error).toContain( + expect(() => tool.build(params)).toThrow( 'File path must be within one of the workspace directories', ); }); - it('should return error for negative offset', () => { + it('should throw error for negative offset', () => { const params: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), offset: -1, limit: 10, }; - expect(tool.validateToolParams(params)).toBe( + expect(() => tool.build(params)).toThrow( 'Offset must be a non-negative number', ); }); - it('should return error for non-positive limit', () => { + it('should throw error for non-positive limit', () => { const paramsZero: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'test.txt'), offset: 0, limit: 0, }; - expect(tool.validateToolParams(paramsZero)).toBe( + expect(() => tool.build(paramsZero)).toThrow( 'Limit must be a positive number', ); const paramsNegative: ReadFileToolParams = { @@ -98,168 +104,182 @@ describe('ReadFileTool', () => { offset: 0, limit: -5, }; - expect(tool.validateToolParams(paramsNegative)).toBe( + expect(() => tool.build(paramsNegative)).toThrow( 'Limit must be a positive number', ); }); - it('should return error for schema validation failure (e.g. missing path)', () => { + it('should throw error for schema validation failure (e.g. missing path)', () => { const params = { offset: 0 } as unknown as ReadFileToolParams; - expect(tool.validateToolParams(params)).toBe( + expect(() => tool.build(params)).toThrow( `params must have required property 'absolute_path'`, ); }); }); - describe('getDescription', () => { - it('should return a shortened, relative path', () => { - const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt'); - const params: ReadFileToolParams = { absolute_path: filePath }; - expect(tool.getDescription(params)).toBe( - path.join('sub', 'dir', 'file.txt'), - ); - }); + describe('ToolInvocation', () => { + describe('getDescription', () => { + it('should return a shortened, relative path', () => { + const filePath = path.join(tempRootDir, 'sub', 'dir', 'file.txt'); + const params: ReadFileToolParams = { absolute_path: filePath }; + const invocation = tool.build(params); + expect(typeof invocation).not.toBe('string'); + expect( + ( + invocation as ToolInvocation + ).getDescription(), + ).toBe(path.join('sub', 'dir', 'file.txt')); + }); - it('should return . if path is the root directory', () => { - const params: ReadFileToolParams = { absolute_path: tempRootDir }; - expect(tool.getDescription(params)).toBe('.'); - }); - }); - - describe('execute', () => { - it('should return validation error if params are invalid', async () => { - const params: ReadFileToolParams = { - absolute_path: 'relative/path.txt', - }; - expect(await tool.execute(params, abortSignal)).toEqual({ - llmContent: - 'Error: Invalid parameters provided. Reason: File path must be absolute, but was relative: relative/path.txt. You must provide an absolute path.', - returnDisplay: - 'File path must be absolute, but was relative: relative/path.txt. You must provide an absolute path.', + it('should return . if path is the root directory', () => { + const params: ReadFileToolParams = { absolute_path: tempRootDir }; + const invocation = tool.build(params); + expect(typeof invocation).not.toBe('string'); + expect( + ( + invocation as ToolInvocation + ).getDescription(), + ).toBe('.'); }); }); - it('should return error if file does not exist', async () => { - const filePath = path.join(tempRootDir, 'nonexistent.txt'); - const params: ReadFileToolParams = { absolute_path: filePath }; + describe('execute', () => { + it('should return error if file does not exist', async () => { + const filePath = path.join(tempRootDir, 'nonexistent.txt'); + const params: ReadFileToolParams = { absolute_path: filePath }; + const invocation = tool.build(params) as ToolInvocation< + ReadFileToolParams, + ToolResult + >; - expect(await tool.execute(params, abortSignal)).toEqual({ - llmContent: `File not found: ${filePath}`, - returnDisplay: 'File not found.', - }); - }); - - it('should return success result for a text file', async () => { - const filePath = path.join(tempRootDir, 'textfile.txt'); - const fileContent = 'This is a test file.'; - await fsp.writeFile(filePath, fileContent, 'utf-8'); - const params: ReadFileToolParams = { absolute_path: filePath }; - - expect(await tool.execute(params, abortSignal)).toEqual({ - llmContent: fileContent, - returnDisplay: '', - }); - }); - - it('should return success result for an image file', async () => { - // A minimal 1x1 transparent PNG file. - const pngContent = Buffer.from([ - 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, - 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68, 65, - 84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1, 13, 10, 45, 180, 0, 0, 0, 0, 73, - 69, 78, 68, 174, 66, 96, 130, - ]); - const filePath = path.join(tempRootDir, 'image.png'); - await fsp.writeFile(filePath, pngContent); - const params: ReadFileToolParams = { absolute_path: filePath }; - - expect(await tool.execute(params, abortSignal)).toEqual({ - llmContent: { - inlineData: { - mimeType: 'image/png', - data: pngContent.toString('base64'), - }, - }, - returnDisplay: `Read image file: image.png`, - }); - }); - - it('should treat a non-image file with image extension as an image', async () => { - const filePath = path.join(tempRootDir, 'fake-image.png'); - const fileContent = 'This is not a real png.'; - await fsp.writeFile(filePath, fileContent, 'utf-8'); - const params: ReadFileToolParams = { absolute_path: filePath }; - - expect(await tool.execute(params, abortSignal)).toEqual({ - llmContent: { - inlineData: { - mimeType: 'image/png', - data: Buffer.from(fileContent).toString('base64'), - }, - }, - returnDisplay: `Read image file: fake-image.png`, - }); - }); - - it('should pass offset and limit to read a slice of a text file', async () => { - const filePath = path.join(tempRootDir, 'paginated.txt'); - const fileContent = Array.from( - { length: 20 }, - (_, i) => `Line ${i + 1}`, - ).join('\n'); - await fsp.writeFile(filePath, fileContent, 'utf-8'); - - const params: ReadFileToolParams = { - absolute_path: filePath, - offset: 5, // Start from line 6 - limit: 3, - }; - - expect(await tool.execute(params, abortSignal)).toEqual({ - llmContent: [ - '[File content truncated: showing lines 6-8 of 20 total lines. Use offset/limit parameters to view more.]', - 'Line 6', - 'Line 7', - 'Line 8', - ].join('\n'), - returnDisplay: 'Read lines 6-8 of 20 from paginated.txt', - }); - }); - - describe('with .geminiignore', () => { - beforeEach(async () => { - await fsp.writeFile( - path.join(tempRootDir, '.geminiignore'), - ['foo.*', 'ignored/'].join('\n'), - ); - }); - - it('should return error if path is ignored by a .geminiignore pattern', async () => { - const ignoredFilePath = path.join(tempRootDir, 'foo.bar'); - await fsp.writeFile(ignoredFilePath, 'content', 'utf-8'); - const params: ReadFileToolParams = { - absolute_path: ignoredFilePath, - }; - const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`; - expect(await tool.execute(params, abortSignal)).toEqual({ - llmContent: `Error: Invalid parameters provided. Reason: ${expectedError}`, - returnDisplay: expectedError, + expect(await invocation.execute(abortSignal)).toEqual({ + llmContent: `File not found: ${filePath}`, + returnDisplay: 'File not found.', }); }); - it('should return error if path is in an ignored directory', async () => { - const ignoredDirPath = path.join(tempRootDir, 'ignored'); - await fsp.mkdir(ignoredDirPath); - const filePath = path.join(ignoredDirPath, 'somefile.txt'); - await fsp.writeFile(filePath, 'content', 'utf-8'); + it('should return success result for a text file', async () => { + const filePath = path.join(tempRootDir, 'textfile.txt'); + const fileContent = 'This is a test file.'; + await fsp.writeFile(filePath, fileContent, 'utf-8'); + const params: ReadFileToolParams = { absolute_path: filePath }; + const invocation = tool.build(params) as ToolInvocation< + ReadFileToolParams, + ToolResult + >; + + expect(await invocation.execute(abortSignal)).toEqual({ + llmContent: fileContent, + returnDisplay: '', + }); + }); + + it('should return success result for an image file', async () => { + // A minimal 1x1 transparent PNG file. + const pngContent = Buffer.from([ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, + 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68, + 65, 84, 120, 156, 99, 0, 1, 0, 0, 5, 0, 1, 13, 10, 45, 180, 0, 0, 0, + 0, 73, 69, 78, 68, 174, 66, 96, 130, + ]); + const filePath = path.join(tempRootDir, 'image.png'); + await fsp.writeFile(filePath, pngContent); + const params: ReadFileToolParams = { absolute_path: filePath }; + const invocation = tool.build(params) as ToolInvocation< + ReadFileToolParams, + ToolResult + >; + + expect(await invocation.execute(abortSignal)).toEqual({ + llmContent: { + inlineData: { + mimeType: 'image/png', + data: pngContent.toString('base64'), + }, + }, + returnDisplay: `Read image file: image.png`, + }); + }); + + it('should treat a non-image file with image extension as an image', async () => { + const filePath = path.join(tempRootDir, 'fake-image.png'); + const fileContent = 'This is not a real png.'; + await fsp.writeFile(filePath, fileContent, 'utf-8'); + const params: ReadFileToolParams = { absolute_path: filePath }; + const invocation = tool.build(params) as ToolInvocation< + ReadFileToolParams, + ToolResult + >; + + expect(await invocation.execute(abortSignal)).toEqual({ + llmContent: { + inlineData: { + mimeType: 'image/png', + data: Buffer.from(fileContent).toString('base64'), + }, + }, + returnDisplay: `Read image file: fake-image.png`, + }); + }); + + it('should pass offset and limit to read a slice of a text file', async () => { + const filePath = path.join(tempRootDir, 'paginated.txt'); + const fileContent = Array.from( + { length: 20 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + await fsp.writeFile(filePath, fileContent, 'utf-8'); const params: ReadFileToolParams = { absolute_path: filePath, + offset: 5, // Start from line 6 + limit: 3, }; - const expectedError = `File path '${filePath}' is ignored by .geminiignore pattern(s).`; - expect(await tool.execute(params, abortSignal)).toEqual({ - llmContent: `Error: Invalid parameters provided. Reason: ${expectedError}`, - returnDisplay: expectedError, + const invocation = tool.build(params) as ToolInvocation< + ReadFileToolParams, + ToolResult + >; + + expect(await invocation.execute(abortSignal)).toEqual({ + llmContent: [ + '[File content truncated: showing lines 6-8 of 20 total lines. Use offset/limit parameters to view more.]', + 'Line 6', + 'Line 7', + 'Line 8', + ].join('\n'), + returnDisplay: 'Read lines 6-8 of 20 from paginated.txt', + }); + }); + + describe('with .geminiignore', () => { + beforeEach(async () => { + await fsp.writeFile( + path.join(tempRootDir, '.geminiignore'), + ['foo.*', 'ignored/'].join('\n'), + ); + }); + + it('should throw error if path is ignored by a .geminiignore pattern', async () => { + const ignoredFilePath = path.join(tempRootDir, 'foo.bar'); + await fsp.writeFile(ignoredFilePath, 'content', 'utf-8'); + const params: ReadFileToolParams = { + absolute_path: ignoredFilePath, + }; + const expectedError = `File path '${ignoredFilePath}' is ignored by .geminiignore pattern(s).`; + expect(() => tool.build(params)).toThrow(expectedError); + }); + + it('should throw error if path is in an ignored directory', async () => { + const ignoredDirPath = path.join(tempRootDir, 'ignored'); + await fsp.mkdir(ignoredDirPath); + const filePath = path.join(ignoredDirPath, 'somefile.txt'); + await fsp.writeFile(filePath, 'content', 'utf-8'); + + const params: ReadFileToolParams = { + absolute_path: filePath, + }; + const expectedError = `File path '${filePath}' is ignored by .geminiignore pattern(s).`; + expect(() => tool.build(params)).toThrow(expectedError); }); }); }); @@ -270,18 +290,16 @@ describe('ReadFileTool', () => { const params: ReadFileToolParams = { absolute_path: path.join(tempRootDir, 'file.txt'), }; - expect(tool.validateToolParams(params)).toBeNull(); + expect(() => tool.build(params)).not.toThrow(); }); it('should reject paths outside workspace root', () => { const params: ReadFileToolParams = { absolute_path: '/etc/passwd', }; - const error = tool.validateToolParams(params); - expect(error).toContain( + expect(() => tool.build(params)).toThrow( 'File path must be within one of the workspace directories', ); - expect(error).toContain(tempRootDir); }); it('should provide clear error message with workspace directories', () => { @@ -289,11 +307,9 @@ describe('ReadFileTool', () => { const params: ReadFileToolParams = { absolute_path: outsidePath, }; - const error = tool.validateToolParams(params); - expect(error).toContain( + expect(() => tool.build(params)).toThrow( 'File path must be within one of the workspace directories', ); - expect(error).toContain(tempRootDir); }); }); }); diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 31282c20..3a05da06 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -7,7 +7,13 @@ import path from 'path'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; -import { BaseTool, Icon, ToolLocation, ToolResult } from './tools.js'; +import { + BaseDeclarativeTool, + Icon, + ToolInvocation, + ToolLocation, + ToolResult, +} from './tools.js'; import { Type } from '@google/genai'; import { processSingleFileContent, @@ -39,10 +45,72 @@ export interface ReadFileToolParams { limit?: number; } +class ReadFileToolInvocation + implements ToolInvocation +{ + constructor( + private config: Config, + public params: ReadFileToolParams, + ) {} + + getDescription(): string { + const relativePath = makeRelative( + this.params.absolute_path, + this.config.getTargetDir(), + ); + return shortenPath(relativePath); + } + + toolLocations(): ToolLocation[] { + return [{ path: this.params.absolute_path, line: this.params.offset }]; + } + + shouldConfirmExecute(): Promise { + return Promise.resolve(false); + } + + async execute(): Promise { + const result = await processSingleFileContent( + this.params.absolute_path, + this.config.getTargetDir(), + this.params.offset, + this.params.limit, + ); + + if (result.error) { + return { + llmContent: result.error, // The detailed error for LLM + returnDisplay: result.returnDisplay || 'Error reading file', // User-friendly error + }; + } + + const lines = + typeof result.llmContent === 'string' + ? result.llmContent.split('\n').length + : undefined; + const mimetype = getSpecificMimeType(this.params.absolute_path); + recordFileOperationMetric( + this.config, + FileOperation.READ, + lines, + mimetype, + path.extname(this.params.absolute_path), + ); + + return { + llmContent: result.llmContent || '', + returnDisplay: result.returnDisplay || '', + }; + } +} + /** * Implementation of the ReadFile tool logic */ -export class ReadFileTool extends BaseTool { +export class ReadFileTool extends BaseDeclarativeTool< + ReadFileToolParams, + ToolResult +> { static readonly Name: string = 'read_file'; constructor(private config: Config) { @@ -75,7 +143,7 @@ export class ReadFileTool extends BaseTool { ); } - validateToolParams(params: ReadFileToolParams): string | null { + protected validateToolParams(params: ReadFileToolParams): string | null { const errors = SchemaValidator.validate(this.schema.parameters, params); if (errors) { return errors; @@ -106,67 +174,9 @@ export class ReadFileTool extends BaseTool { return null; } - getDescription(params: ReadFileToolParams): string { - if ( - !params || - typeof params.absolute_path !== 'string' || - params.absolute_path.trim() === '' - ) { - return `Path unavailable`; - } - const relativePath = makeRelative( - params.absolute_path, - this.config.getTargetDir(), - ); - return shortenPath(relativePath); - } - - toolLocations(params: ReadFileToolParams): ToolLocation[] { - return [{ path: params.absolute_path, line: params.offset }]; - } - - async execute( + protected createInvocation( params: ReadFileToolParams, - _signal: AbortSignal, - ): Promise { - const validationError = this.validateToolParams(params); - if (validationError) { - return { - llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, - returnDisplay: validationError, - }; - } - - const result = await processSingleFileContent( - params.absolute_path, - this.config.getTargetDir(), - params.offset, - params.limit, - ); - - if (result.error) { - return { - llmContent: result.error, // The detailed error for LLM - returnDisplay: result.returnDisplay || 'Error reading file', // User-friendly error - }; - } - - const lines = - typeof result.llmContent === 'string' - ? result.llmContent.split('\n').length - : undefined; - const mimetype = getSpecificMimeType(params.absolute_path); - recordFileOperationMetric( - this.config, - FileOperation.READ, - lines, - mimetype, - path.extname(params.absolute_path), - ); - - return { - llmContent: result.llmContent || '', - returnDisplay: result.returnDisplay || '', - }; + ): ToolInvocation { + return new ReadFileToolInvocation(this.config, params); } } diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 24b6ca5f..e7c71e14 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -21,7 +21,6 @@ import { sanitizeParameters, } from './tool-registry.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; -import { BaseTool, Icon, ToolResult } from './tools.js'; import { FunctionDeclaration, CallableTool, @@ -32,6 +31,7 @@ import { import { spawn } from 'node:child_process'; import fs from 'node:fs'; +import { MockTool } from '../test-utils/tools.js'; vi.mock('node:fs'); @@ -107,28 +107,6 @@ const createMockCallableTool = ( callTool: vi.fn(), }); -class MockTool extends BaseTool<{ param: string }, ToolResult> { - constructor( - name = 'mock-tool', - displayName = 'A mock tool', - description = 'A mock tool description', - ) { - super(name, displayName, description, Icon.Hammer, { - type: Type.OBJECT, - properties: { - param: { type: Type.STRING }, - }, - required: ['param'], - }); - } - async execute(params: { param: string }): Promise { - return { - llmContent: `Executed with ${params.param}`, - returnDisplay: `Executed with ${params.param}`, - }; - } -} - const baseConfigParams: ConfigParameters = { cwd: '/tmp', model: 'test-model', diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index e60b8f74..73b427d4 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -5,7 +5,7 @@ */ import { FunctionDeclaration, Schema, Type } from '@google/genai'; -import { Tool, ToolResult, BaseTool, Icon } from './tools.js'; +import { AnyDeclarativeTool, Icon, ToolResult, BaseTool } from './tools.js'; import { Config } from '../config/config.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; @@ -125,7 +125,7 @@ Signal: Signal number or \`(none)\` if no signal was received. } export class ToolRegistry { - private tools: Map = new Map(); + private tools: Map = new Map(); private config: Config; constructor(config: Config) { @@ -136,7 +136,7 @@ export class ToolRegistry { * Registers a tool definition. * @param tool - The tool object containing schema and execution logic. */ - registerTool(tool: Tool): void { + registerTool(tool: AnyDeclarativeTool): void { if (this.tools.has(tool.name)) { if (tool instanceof DiscoveredMCPTool) { tool = tool.asFullyQualifiedTool(); @@ -368,7 +368,7 @@ export class ToolRegistry { /** * Returns an array of all registered and discovered tool instances. */ - getAllTools(): Tool[] { + getAllTools(): AnyDeclarativeTool[] { return Array.from(this.tools.values()).sort((a, b) => a.displayName.localeCompare(b.displayName), ); @@ -377,8 +377,8 @@ export class ToolRegistry { /** * Returns an array of tools registered from a specific MCP server. */ - getToolsByServer(serverName: string): Tool[] { - const serverTools: Tool[] = []; + getToolsByServer(serverName: string): AnyDeclarativeTool[] { + const serverTools: AnyDeclarativeTool[] = []; for (const tool of this.tools.values()) { if ((tool as DiscoveredMCPTool)?.serverName === serverName) { serverTools.push(tool); @@ -390,7 +390,7 @@ export class ToolRegistry { /** * Get the definition of a specific tool. */ - getTool(name: string): Tool | undefined { + getTool(name: string): AnyDeclarativeTool | undefined { return this.tools.get(name); } } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 3404093f..79e6f010 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -9,101 +9,243 @@ import { ToolErrorType } from './tool-error.js'; import { DiffUpdateResult } from '../ide/ideContext.js'; /** - * Interface representing the base Tool functionality + * Represents a validated and ready-to-execute tool call. + * An instance of this is created by a `ToolBuilder`. */ -export interface Tool< - TParams = unknown, - TResult extends ToolResult = ToolResult, +export interface ToolInvocation< + TParams extends object, + TResult extends ToolResult, > { /** - * The internal name of the tool (used for API calls) + * The validated parameters for this specific invocation. */ - name: string; + params: TParams; /** - * The user-friendly display name of the tool + * Gets a pre-execution description of the tool operation. + * @returns A markdown string describing what the tool will do. */ - displayName: string; + getDescription(): string; /** - * Description of what the tool does + * Determines what file system paths the tool will affect. + * @returns A list of such paths. */ - description: string; + toolLocations(): ToolLocation[]; /** - * The icon to display when interacting via ACP - */ - icon: Icon; - - /** - * Function declaration schema from @google/genai - */ - schema: FunctionDeclaration; - - /** - * Whether the tool's output should be rendered as markdown - */ - isOutputMarkdown: boolean; - - /** - * Whether the tool supports live (streaming) output - */ - canUpdateOutput: boolean; - - /** - * Validates the parameters for the tool - * Should be called from both `shouldConfirmExecute` and `execute` - * `shouldConfirmExecute` should return false immediately if invalid - * @param params Parameters to validate - * @returns An error message string if invalid, null otherwise - */ - validateToolParams(params: TParams): string | null; - - /** - * Gets a pre-execution description of the tool operation - * @param params Parameters for the tool execution - * @returns A markdown string describing what the tool will do - * Optional for backward compatibility - */ - getDescription(params: TParams): string; - - /** - * Determines what file system paths the tool will affect - * @param params Parameters for the tool execution - * @returns A list of such paths - */ - toolLocations(params: TParams): ToolLocation[]; - - /** - * Determines if the tool should prompt for confirmation before execution - * @param params Parameters for the tool execution - * @returns Whether execute should be confirmed. + * Determines if the tool should prompt for confirmation before execution. + * @returns Confirmation details or false if no confirmation is needed. */ shouldConfirmExecute( - params: TParams, abortSignal: AbortSignal, ): Promise; /** - * Executes the tool with the given parameters - * @param params Parameters for the tool execution - * @returns Result of the tool execution + * Executes the tool with the validated parameters. + * @param signal AbortSignal for tool cancellation. + * @param updateOutput Optional callback to stream output. + * @returns Result of the tool execution. */ execute( - params: TParams, signal: AbortSignal, updateOutput?: (output: string) => void, ): Promise; } +/** + * A type alias for a tool invocation where the specific parameter and result types are not known. + */ +export type AnyToolInvocation = ToolInvocation; + +/** + * An adapter that wraps the legacy `Tool` interface to make it compatible + * with the new `ToolInvocation` pattern. + */ +export class LegacyToolInvocation< + TParams extends object, + TResult extends ToolResult, +> implements ToolInvocation +{ + constructor( + private readonly legacyTool: BaseTool, + readonly params: TParams, + ) {} + + getDescription(): string { + return this.legacyTool.getDescription(this.params); + } + + toolLocations(): ToolLocation[] { + return this.legacyTool.toolLocations(this.params); + } + + shouldConfirmExecute( + abortSignal: AbortSignal, + ): Promise { + return this.legacyTool.shouldConfirmExecute(this.params, abortSignal); + } + + execute( + signal: AbortSignal, + updateOutput?: (output: string) => void, + ): Promise { + return this.legacyTool.execute(this.params, signal, updateOutput); + } +} + +/** + * Interface for a tool builder that validates parameters and creates invocations. + */ +export interface ToolBuilder< + TParams extends object, + TResult extends ToolResult, +> { + /** + * The internal name of the tool (used for API calls). + */ + name: string; + + /** + * The user-friendly display name of the tool. + */ + displayName: string; + + /** + * Description of what the tool does. + */ + description: string; + + /** + * The icon to display when interacting via ACP. + */ + icon: Icon; + + /** + * Function declaration schema from @google/genai. + */ + schema: FunctionDeclaration; + + /** + * Whether the tool's output should be rendered as markdown. + */ + isOutputMarkdown: boolean; + + /** + * Whether the tool supports live (streaming) output. + */ + canUpdateOutput: boolean; + + /** + * Validates raw parameters and builds a ready-to-execute invocation. + * @param params The raw, untrusted parameters from the model. + * @returns A valid `ToolInvocation` if successful. Throws an error if validation fails. + */ + build(params: TParams): ToolInvocation; +} + +/** + * New base class for tools that separates validation from execution. + * New tools should extend this class. + */ +export abstract class DeclarativeTool< + TParams extends object, + TResult extends ToolResult, +> implements ToolBuilder +{ + constructor( + readonly name: string, + readonly displayName: string, + readonly description: string, + readonly icon: Icon, + readonly parameterSchema: Schema, + readonly isOutputMarkdown: boolean = true, + readonly canUpdateOutput: boolean = false, + ) {} + + get schema(): FunctionDeclaration { + return { + name: this.name, + description: this.description, + parameters: this.parameterSchema, + }; + } + + /** + * Validates the raw tool parameters. + * Subclasses should override this to add custom validation logic + * beyond the JSON schema check. + * @param params The raw parameters from the model. + * @returns An error message string if invalid, null otherwise. + */ + protected validateToolParams(_params: TParams): string | null { + // Base implementation can be extended by subclasses. + return null; + } + + /** + * The core of the new pattern. It validates parameters and, if successful, + * returns a `ToolInvocation` object that encapsulates the logic for the + * specific, validated call. + * @param params The raw, untrusted parameters from the model. + * @returns A `ToolInvocation` instance. + */ + abstract build(params: TParams): ToolInvocation; + + /** + * A convenience method that builds and executes the tool in one step. + * Throws an error if validation fails. + * @param params The raw, untrusted parameters from the model. + * @param signal AbortSignal for tool cancellation. + * @param updateOutput Optional callback to stream output. + * @returns The result of the tool execution. + */ + async buildAndExecute( + params: TParams, + signal: AbortSignal, + updateOutput?: (output: string) => void, + ): Promise { + const invocation = this.build(params); + return invocation.execute(signal, updateOutput); + } +} + +/** + * New base class for declarative tools that separates validation from execution. + * New tools should extend this class, which provides a `build` method that + * validates parameters before deferring to a `createInvocation` method for + * the final `ToolInvocation` object instantiation. + */ +export abstract class BaseDeclarativeTool< + TParams extends object, + TResult extends ToolResult, +> extends DeclarativeTool { + build(params: TParams): ToolInvocation { + const validationError = this.validateToolParams(params); + if (validationError) { + throw new Error(validationError); + } + return this.createInvocation(params); + } + + protected abstract createInvocation( + params: TParams, + ): ToolInvocation; +} + +/** + * A type alias for a declarative tool where the specific parameter and result types are not known. + */ +export type AnyDeclarativeTool = DeclarativeTool; + /** * Base implementation for tools with common functionality + * @deprecated Use `DeclarativeTool` for new tools. */ export abstract class BaseTool< - TParams = unknown, + TParams extends object, TResult extends ToolResult = ToolResult, -> implements Tool -{ +> extends DeclarativeTool { /** * Creates a new instance of BaseTool * @param name Internal name of the tool (used for API calls) @@ -121,17 +263,24 @@ export abstract class BaseTool< readonly parameterSchema: Schema, readonly isOutputMarkdown: boolean = true, readonly canUpdateOutput: boolean = false, - ) {} + ) { + super( + name, + displayName, + description, + icon, + parameterSchema, + isOutputMarkdown, + canUpdateOutput, + ); + } - /** - * Function declaration schema computed from name, description, and parameterSchema - */ - get schema(): FunctionDeclaration { - return { - name: this.name, - description: this.description, - parameters: this.parameterSchema, - }; + build(params: TParams): ToolInvocation { + const validationError = this.validateToolParams(params); + if (validationError) { + throw new Error(validationError); + } + return new LegacyToolInvocation(this, params); } /** diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 32ecc068..9e7e3813 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -26,7 +26,7 @@ import { ensureCorrectFileContent, } from '../utils/editCorrector.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; -import { ModifiableTool, ModifyContext } from './modifiable-tool.js'; +import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js'; import { getSpecificMimeType } from '../utils/fileUtils.js'; import { recordFileOperationMetric, @@ -66,7 +66,7 @@ interface GetCorrectedFileContentResult { */ export class WriteFileTool extends BaseTool - implements ModifiableTool + implements ModifiableDeclarativeTool { static readonly Name: string = 'write_file'; From 1f0ad865444c07481385c39b272f9ec2b94d41b9 Mon Sep 17 00:00:00 2001 From: shishu314 Date: Wed, 6 Aug 2025 15:19:10 -0400 Subject: [PATCH 017/107] fix: Restore user input when the user cancels response (#5601) Co-authored-by: Shi Shu Co-authored-by: Jacob Richman --- packages/cli/src/ui/App.tsx | 29 +++++--- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 66 +++++++++++++++++++ packages/cli/src/ui/hooks/useGeminiStream.ts | 2 + 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 66396c36..f2dcc79e 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -486,6 +486,24 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { setGeminiMdFileCount, ); + const buffer = useTextBuffer({ + initialText: '', + viewport: { height: 10, width: inputWidth }, + stdin, + setRawMode, + isValidPath, + shellModeActive, + }); + + const [userMessages, setUserMessages] = useState([]); + + const handleUserCancel = useCallback(() => { + const lastUserMessage = userMessages.at(-1); + if (lastUserMessage) { + buffer.setText(lastUserMessage); + } + }, [buffer, userMessages]); + const { streamingState, submitQuery, @@ -506,6 +524,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError, refreshStatic, + handleUserCancel, ); // Input handling @@ -519,15 +538,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { [submitQuery], ); - const buffer = useTextBuffer({ - initialText: '', - viewport: { height: 10, width: inputWidth }, - stdin, - setRawMode, - isValidPath, - shellModeActive, - }); - const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; pendingHistoryItems.push(...pendingGeminiHistoryItems); @@ -607,7 +617,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { }, [config, config.getGeminiMdFileCount]); const logger = useLogger(); - const [userMessages, setUserMessages] = useState([]); useEffect(() => { const fetchUserMessages = async () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index dd2428bb..751b869e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -406,6 +406,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ); }, { @@ -560,6 +562,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -633,6 +637,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -737,6 +743,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -843,6 +851,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -943,6 +953,44 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Idle); }); + it('should call onCancelSubmit handler when escape is pressed', async () => { + const cancelSubmitSpy = vi.fn(); + const mockStream = (async function* () { + yield { type: 'content', value: 'Part 1' }; + // Keep the stream open + await new Promise(() => {}); + })(); + mockSendMessageStream.mockReturnValue(mockStream); + + const { result } = renderHook(() => + useGeminiStream( + mockConfig.getGeminiClient(), + [], + mockAddItem, + mockConfig, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + cancelSubmitSpy, + ), + ); + + // Start a query + await act(async () => { + result.current.submitQuery('test query'); + }); + + simulateEscapeKeyPress(); + + expect(cancelSubmitSpy).toHaveBeenCalled(); + }); + it('should not do anything if escape is pressed when not responding', () => { const { result } = renderTestHook(); @@ -1202,6 +1250,8 @@ describe('useGeminiStream', () => { mockPerformMemoryRefresh, false, () => {}, + () => {}, + () => {}, ), ); @@ -1253,6 +1303,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -1301,6 +1353,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -1347,6 +1401,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -1394,6 +1450,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -1481,6 +1539,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -1535,6 +1595,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -1611,6 +1673,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); @@ -1663,6 +1727,8 @@ describe('useGeminiStream', () => { () => Promise.resolve(), false, () => {}, + () => {}, + () => {}, ), ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 63ba961f..58bec431 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -94,6 +94,7 @@ export const useGeminiStream = ( modelSwitchedFromQuotaError: boolean, setModelSwitchedFromQuotaError: React.Dispatch>, onEditorClose: () => void, + onCancelSubmit: () => void, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -200,6 +201,7 @@ export const useGeminiStream = ( Date.now(), ); setPendingHistoryItem(null); + onCancelSubmit(); setIsResponding(false); } }); From 1fb680baccf93fee5c96167da96fd31e4d57cf6f Mon Sep 17 00:00:00 2001 From: Lee James <40045512+leehagoodjames@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:26:46 -0400 Subject: [PATCH 018/107] bug(tests): fix test errors (#5678) Co-authored-by: matt korwel --- eslint.config.js | 1 + package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index a1194df7..e639e689 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,7 @@ export default tseslint.config( 'packages/vscode-ide-companion/dist/**', 'bundle/**', 'package/bundle/**', + '.integration-tests/**', ], }, eslint.configs.recommended, diff --git a/package.json b/package.json index bb7896c5..17442eaa 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "build:packages": "npm run build --workspaces", "build:sandbox": "node scripts/build_sandbox.js --skip-npm-install-build", "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js", - "test": "npm run test --workspaces", + "test": "npm run test --workspaces --if-present", "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:e2e": "npm run test:integration:sandbox:none -- --verbose --keep-output", From 024b8207eb75bdc0c031f6380d6759b9e342e502 Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Wed, 6 Aug 2025 15:47:58 -0400 Subject: [PATCH 019/107] Add hint to enable IDE integration for users running in VS Code (#5610) --- packages/cli/src/config/settings.ts | 3 + packages/cli/src/ui/App.test.tsx | 8 +- packages/cli/src/ui/App.tsx | 45 ++++++++- packages/cli/src/ui/IdeIntegrationNudge.tsx | 70 +++++++++++++ packages/vscode-ide-companion/package.json | 4 +- .../src/extension.test.ts | 99 +++++++++++++++++++ .../vscode-ide-companion/src/extension.ts | 20 ++++ 7 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/ui/IdeIntegrationNudge.tsx create mode 100644 packages/vscode-ide-companion/src/extension.test.ts diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index bb8c87b8..93641ae0 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -115,6 +115,9 @@ export interface Settings { /// IDE mode setting configured via slash command toggle. ideMode?: boolean; + // Setting to track if the user has seen the IDE integration nudge. + hasSeenIdeIntegrationNudge?: boolean; + // Setting for disabling auto-update. disableAutoUpdate?: boolean; diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index fc6dbb5a..a5c2a9c6 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -16,6 +16,7 @@ import { SandboxConfig, GeminiClient, ideContext, + type AuthType, } from '@google/gemini-cli-core'; import { LoadedSettings, SettingsFile, Settings } from '../config/settings.js'; import process from 'node:process'; @@ -84,6 +85,7 @@ interface MockServerConfig { getAllGeminiMdFilenames: Mock<() => string[]>; getGeminiClient: Mock<() => GeminiClient | undefined>; getUserTier: Mock<() => Promise>; + getIdeClient: Mock<() => { getCurrentIde: Mock<() => string | undefined> }>; } // Mock @google/gemini-cli-core and its Config class @@ -157,6 +159,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { getWorkspaceContext: vi.fn(() => ({ getDirectories: vi.fn(() => []), })), + getIdeClient: vi.fn(() => ({ + getCurrentIde: vi.fn(() => 'vscode'), + })), }; }); @@ -182,6 +187,7 @@ vi.mock('./hooks/useGeminiStream', () => ({ submitQuery: vi.fn(), initError: null, pendingHistoryItems: [], + thought: null, })), })); @@ -233,7 +239,7 @@ vi.mock('./utils/updateCheck.js', () => ({ checkForUpdates: vi.fn(), })); -vi.mock('./config/auth.js', () => ({ +vi.mock('../config/auth.js', () => ({ validateAuthMethod: vi.fn(), })); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index f2dcc79e..2be681e5 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -39,7 +39,7 @@ import { EditorSettingsDialog } from './components/EditorSettingsDialog.js'; import { ShellConfirmationDialog } from './components/ShellConfirmationDialog.js'; import { Colors } from './colors.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; -import { LoadedSettings } from '../config/settings.js'; +import { LoadedSettings, SettingScope } from '../config/settings.js'; import { Tips } from './components/Tips.js'; import { ConsolePatcher } from './utils/ConsolePatcher.js'; import { registerCleanup } from '../utils/cleanup.js'; @@ -62,6 +62,10 @@ import { type IdeContext, ideContext, } from '@google/gemini-cli-core'; +import { + IdeIntegrationNudge, + IdeIntegrationNudgeResult, +} from './IdeIntegrationNudge.js'; import { validateAuthMethod } from '../config/auth.js'; import { useLogger } from './hooks/useLogger.js'; import { StreamingContext } from './contexts/StreamingContext.js'; @@ -115,6 +119,15 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { const nightly = version.includes('nightly'); const { history, addItem, clearItems, loadHistory } = useHistory(); + const [idePromptAnswered, setIdePromptAnswered] = useState(false); + const currentIDE = config.getIdeClient().getCurrentIde(); + const shouldShowIdePrompt = + config.getIdeModeFeature() && + currentIDE && + !config.getIdeMode() && + !settings.merged.hasSeenIdeIntegrationNudge && + !idePromptAnswered; + useEffect(() => { const cleanup = setUpdateHandler(addItem, setUpdateInfo); return cleanup; @@ -538,6 +551,27 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { [submitQuery], ); + const handleIdePromptComplete = useCallback( + (result: IdeIntegrationNudgeResult) => { + if (result === 'yes') { + handleSlashCommand('/ide install'); + settings.setValue( + SettingScope.User, + 'hasSeenIdeIntegrationNudge', + true, + ); + } else if (result === 'dismiss') { + settings.setValue( + SettingScope.User, + 'hasSeenIdeIntegrationNudge', + true, + ); + } + setIdePromptAnswered(true); + }, + [handleSlashCommand, settings], + ); + const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; pendingHistoryItems.push(...pendingGeminiHistoryItems); @@ -768,6 +802,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ); } + const mainAreaWidth = Math.floor(terminalWidth * 0.9); const debugConsoleMaxHeight = Math.floor(Math.max(terminalHeight * 0.2, 5)); // Arbitrary threshold to ensure that items in the static area are large @@ -859,7 +894,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { )} - {shellConfirmationRequest ? ( + {shouldShowIdePrompt ? ( + + ) : shellConfirmationRequest ? ( ) : isThemeDialogOpen ? ( diff --git a/packages/cli/src/ui/IdeIntegrationNudge.tsx b/packages/cli/src/ui/IdeIntegrationNudge.tsx new file mode 100644 index 00000000..72cd1756 --- /dev/null +++ b/packages/cli/src/ui/IdeIntegrationNudge.tsx @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text, useInput } from 'ink'; +import { + RadioButtonSelect, + RadioSelectItem, +} from './components/shared/RadioButtonSelect.js'; + +export type IdeIntegrationNudgeResult = 'yes' | 'no' | 'dismiss'; + +interface IdeIntegrationNudgeProps { + question: string; + description?: string; + onComplete: (result: IdeIntegrationNudgeResult) => void; +} + +export function IdeIntegrationNudge({ + question, + description, + onComplete, +}: IdeIntegrationNudgeProps) { + useInput((_input, key) => { + if (key.escape) { + onComplete('no'); + } + }); + + const OPTIONS: Array> = [ + { + label: 'Yes', + value: 'yes', + }, + { + label: 'No (esc)', + value: 'no', + }, + { + label: "No, don't ask again", + value: 'dismiss', + }, + ]; + + return ( + + + + {'> '} + {question} + + {description && {description}} + + + + ); +} diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 263f1b18..aee14e32 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -39,12 +39,12 @@ "commands": [ { "command": "gemini.diff.accept", - "title": "Gemini CLI: Accept Current Diff", + "title": "Gemini CLI: Accept Diff", "icon": "$(check)" }, { "command": "gemini.diff.cancel", - "title": "Cancel", + "title": "Gemini CLI: Close Diff Editor", "icon": "$(close)" }, { diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts new file mode 100644 index 00000000..89d1821f --- /dev/null +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { activate } from './extension.js'; + +vi.mock('vscode', () => ({ + window: { + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + })), + showInformationMessage: vi.fn(), + createTerminal: vi.fn(() => ({ + show: vi.fn(), + sendText: vi.fn(), + })), + }, + workspace: { + workspaceFolders: [], + onDidCloseTextDocument: vi.fn(), + registerTextDocumentContentProvider: vi.fn(), + onDidChangeWorkspaceFolders: vi.fn(), + }, + commands: { + registerCommand: vi.fn(), + executeCommand: vi.fn(), + }, + Uri: { + joinPath: vi.fn(), + }, + ExtensionMode: { + Development: 1, + Production: 2, + }, + EventEmitter: vi.fn(() => ({ + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + })), +})); + +describe('activate', () => { + let context: vscode.ExtensionContext; + + beforeEach(() => { + context = { + subscriptions: [], + environmentVariableCollection: { + replace: vi.fn(), + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + extensionUri: { + fsPath: '/path/to/extension', + }, + } as unknown as vscode.ExtensionContext; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should show the info message on first activation', async () => { + const showInformationMessageMock = vi + .mocked(vscode.window.showInformationMessage) + .mockResolvedValue(undefined as never); + vi.mocked(context.globalState.get).mockReturnValue(undefined); + await activate(context); + expect(showInformationMessageMock).toHaveBeenCalledWith( + 'Gemini CLI Companion extension successfully installed. Please restart your terminal to enable full IDE integration.', + 'Re-launch Gemini CLI', + ); + }); + + it('should not show the info message on subsequent activations', async () => { + vi.mocked(context.globalState.get).mockReturnValue(true); + await activate(context); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it('should launch the Gemini CLI when the user clicks the button', async () => { + const showInformationMessageMock = vi + .mocked(vscode.window.showInformationMessage) + .mockResolvedValue('Re-launch Gemini CLI' as never); + vi.mocked(context.globalState.get).mockReturnValue(undefined); + await activate(context); + expect(showInformationMessageMock).toHaveBeenCalled(); + await new Promise(process.nextTick); // Wait for the promise to resolve + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'gemini-cli.runGeminiCLI', + ); + }); +}); diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index b31e15b8..08389731 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -9,6 +9,7 @@ import { IDEServer } from './ide-server.js'; import { DiffContentProvider, DiffManager } from './diff-manager.js'; import { createLogger } from './utils/logger.js'; +const INFO_MESSAGE_SHOWN_KEY = 'geminiCliInfoMessageShown'; const IDE_WORKSPACE_PATH_ENV_VAR = 'GEMINI_CLI_IDE_WORKSPACE_PATH'; export const DIFF_SCHEME = 'gemini-diff'; @@ -81,6 +82,25 @@ export async function activate(context: vscode.ExtensionContext) { log(`Failed to start IDE server: ${message}`); } + if (!context.globalState.get(INFO_MESSAGE_SHOWN_KEY)) { + void vscode.window + .showInformationMessage( + 'Gemini CLI Companion extension successfully installed. Please restart your terminal to enable full IDE integration.', + 'Re-launch Gemini CLI', + ) + .then( + (selection) => { + if (selection === 'Re-launch Gemini CLI') { + void vscode.commands.executeCommand('gemini-cli.runGeminiCLI'); + } + }, + (err) => { + log(`Failed to show information message: ${String(err)}`); + }, + ); + context.globalState.update(INFO_MESSAGE_SHOWN_KEY, true); + } + context.subscriptions.push( vscode.workspace.onDidChangeWorkspaceFolders(() => { updateWorkspacePath(context); From b3cfaeb6d30101262dc2b7350f5a349cd0417386 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Wed, 6 Aug 2025 13:19:15 -0700 Subject: [PATCH 020/107] Add detection of tools with bad schemas and automatically omit them with a warning (#5694) --- packages/core/src/tools/mcp-client.test.ts | 336 +++++++++++++++++++++ packages/core/src/tools/mcp-client.ts | 68 +++++ 2 files changed, 404 insertions(+) diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 9997d60e..1ccba76a 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -12,6 +12,7 @@ import { isEnabled, discoverTools, discoverPrompts, + hasValidTypes, } from './mcp-client.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import * as SdkClientStdioLib from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -97,6 +98,182 @@ describe('mcp-client', () => { `Error discovering tool: 'invalid tool name' from MCP server 'test-server': ${testError.message}`, ); }); + + it('should skip tools if a parameter is missing a type', async () => { + const mockedClient = {} as unknown as ClientLib.Client; + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'validTool', + parametersJsonSchema: { + type: 'object', + properties: { + param1: { type: 'string' }, + }, + }, + }, + { + name: 'invalidTool', + parametersJsonSchema: { + type: 'object', + properties: { + param1: { description: 'a param with no type' }, + }, + }, + }, + ], + }), + } as unknown as GenAiLib.CallableTool); + + const tools = await discoverTools('test-server', {}, mockedClient); + + expect(tools.length).toBe(1); + expect(vi.mocked(DiscoveredMCPTool).mock.calls[0][2]).toBe('validTool'); + expect(consoleWarnSpy).toHaveBeenCalledOnce(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Skipping tool 'invalidTool' from MCP server 'test-server' because it has ` + + `missing types in its parameter schema. Please file an issue with the owner of the MCP server.`, + ); + consoleWarnSpy.mockRestore(); + }); + + it('should skip tools if a nested parameter is missing a type', async () => { + const mockedClient = {} as unknown as ClientLib.Client; + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'invalidTool', + parametersJsonSchema: { + type: 'object', + properties: { + param1: { + type: 'object', + properties: { + nestedParam: { + description: 'a nested param with no type', + }, + }, + }, + }, + }, + }, + ], + }), + } as unknown as GenAiLib.CallableTool); + + const tools = await discoverTools('test-server', {}, mockedClient); + + expect(tools.length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledOnce(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Skipping tool 'invalidTool' from MCP server 'test-server' because it has ` + + `missing types in its parameter schema. Please file an issue with the owner of the MCP server.`, + ); + consoleWarnSpy.mockRestore(); + }); + + it('should skip tool if an array item is missing a type', async () => { + const mockedClient = {} as unknown as ClientLib.Client; + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'invalidTool', + parametersJsonSchema: { + type: 'object', + properties: { + param1: { + type: 'array', + items: { + description: 'an array item with no type', + }, + }, + }, + }, + }, + ], + }), + } as unknown as GenAiLib.CallableTool); + + const tools = await discoverTools('test-server', {}, mockedClient); + + expect(tools.length).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledOnce(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Skipping tool 'invalidTool' from MCP server 'test-server' because it has ` + + `missing types in its parameter schema. Please file an issue with the owner of the MCP server.`, + ); + consoleWarnSpy.mockRestore(); + }); + + it('should discover tool with no properties in schema', async () => { + const mockedClient = {} as unknown as ClientLib.Client; + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'validTool', + parametersJsonSchema: { + type: 'object', + }, + }, + ], + }), + } as unknown as GenAiLib.CallableTool); + + const tools = await discoverTools('test-server', {}, mockedClient); + + expect(tools.length).toBe(1); + expect(vi.mocked(DiscoveredMCPTool).mock.calls[0][2]).toBe('validTool'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); + + it('should discover tool with empty properties object in schema', async () => { + const mockedClient = {} as unknown as ClientLib.Client; + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ + tool: () => + Promise.resolve({ + functionDeclarations: [ + { + name: 'validTool', + parametersJsonSchema: { + type: 'object', + properties: {}, + }, + }, + ], + }), + } as unknown as GenAiLib.CallableTool); + + const tools = await discoverTools('test-server', {}, mockedClient); + + expect(tools.length).toBe(1); + expect(vi.mocked(DiscoveredMCPTool).mock.calls[0][2]).toBe('validTool'); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); }); describe('discoverPrompts', () => { @@ -433,4 +610,163 @@ describe('mcp-client', () => { ); }); }); + + describe('hasValidTypes', () => { + it('should return true for a valid schema with anyOf', () => { + const schema = { + anyOf: [{ type: 'string' }, { type: 'number' }], + }; + expect(hasValidTypes(schema)).toBe(true); + }); + + it('should return false for an invalid schema with anyOf', () => { + const schema = { + anyOf: [{ type: 'string' }, { description: 'no type' }], + }; + expect(hasValidTypes(schema)).toBe(false); + }); + + it('should return true for a valid schema with allOf', () => { + const schema = { + allOf: [ + { type: 'string' }, + { type: 'object', properties: { foo: { type: 'string' } } }, + ], + }; + expect(hasValidTypes(schema)).toBe(true); + }); + + it('should return false for an invalid schema with allOf', () => { + const schema = { + allOf: [{ type: 'string' }, { description: 'no type' }], + }; + expect(hasValidTypes(schema)).toBe(false); + }); + + it('should return true for a valid schema with oneOf', () => { + const schema = { + oneOf: [{ type: 'string' }, { type: 'number' }], + }; + expect(hasValidTypes(schema)).toBe(true); + }); + + it('should return false for an invalid schema with oneOf', () => { + const schema = { + oneOf: [{ type: 'string' }, { description: 'no type' }], + }; + expect(hasValidTypes(schema)).toBe(false); + }); + + it('should return true for a valid schema with nested subschemas', () => { + const schema = { + anyOf: [ + { type: 'string' }, + { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + }, + ], + }; + expect(hasValidTypes(schema)).toBe(true); + }); + + it('should return false for an invalid schema with nested subschemas', () => { + const schema = { + anyOf: [ + { type: 'string' }, + { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { description: 'no type' }, + ], + }, + ], + }; + expect(hasValidTypes(schema)).toBe(false); + }); + + it('should return true for a schema with a type and subschemas', () => { + const schema = { + type: 'string', + anyOf: [{ minLength: 1 }, { maxLength: 5 }], + }; + expect(hasValidTypes(schema)).toBe(true); + }); + + it('should return false for a schema with no type and no subschemas', () => { + const schema = { + description: 'a schema with no type', + }; + expect(hasValidTypes(schema)).toBe(false); + }); + + it('should return true for a valid schema', () => { + const schema = { + type: 'object', + properties: { + param1: { type: 'string' }, + }, + }; + expect(hasValidTypes(schema)).toBe(true); + }); + + it('should return false if a parameter is missing a type', () => { + const schema = { + type: 'object', + properties: { + param1: { description: 'a param with no type' }, + }, + }; + expect(hasValidTypes(schema)).toBe(false); + }); + + it('should return false if a nested parameter is missing a type', () => { + const schema = { + type: 'object', + properties: { + param1: { + type: 'object', + properties: { + nestedParam: { + description: 'a nested param with no type', + }, + }, + }, + }, + }; + expect(hasValidTypes(schema)).toBe(false); + }); + + it('should return false if an array item is missing a type', () => { + const schema = { + type: 'object', + properties: { + param1: { + type: 'array', + items: { + description: 'an array item with no type', + }, + }, + }, + }; + expect(hasValidTypes(schema)).toBe(false); + }); + + it('should return true for a schema with no properties', () => { + const schema = { + type: 'object', + }; + expect(hasValidTypes(schema)).toBe(true); + }); + + it('should return true for a schema with an empty properties object', () => { + const schema = { + type: 'object', + properties: {}, + }; + expect(hasValidTypes(schema)).toBe(true); + }); + }); }); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 26244d9e..9a35b84e 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -416,6 +416,65 @@ export async function connectAndDiscover( } } +/** + * Recursively validates that a JSON schema and all its nested properties and + * items have a `type` defined. + * + * @param schema The JSON schema to validate. + * @returns `true` if the schema is valid, `false` otherwise. + * + * @visiblefortesting + */ +export function hasValidTypes(schema: unknown): boolean { + if (typeof schema !== 'object' || schema === null) { + // Not a schema object we can validate, or not a schema at all. + // Treat as valid as it has no properties to be invalid. + return true; + } + + const s = schema as Record; + + if (!s.type) { + // These keywords contain an array of schemas that should be validated. + // + // If no top level type was given, then they must each have a type. + let hasSubSchema = false; + const schemaArrayKeywords = ['anyOf', 'allOf', 'oneOf']; + for (const keyword of schemaArrayKeywords) { + const subSchemas = s[keyword]; + if (Array.isArray(subSchemas)) { + hasSubSchema = true; + for (const subSchema of subSchemas) { + if (!hasValidTypes(subSchema)) { + return false; + } + } + } + } + + // If the node itself is missing a type and had no subschemas, then it isn't valid. + if (!hasSubSchema) return false; + } + + if (s.type === 'object' && s.properties) { + if (typeof s.properties === 'object' && s.properties !== null) { + for (const prop of Object.values(s.properties)) { + if (!hasValidTypes(prop)) { + return false; + } + } + } + } + + if (s.type === 'array' && s.items) { + if (!hasValidTypes(s.items)) { + return false; + } + } + + return true; +} + /** * Discovers and sanitizes tools from a connected MCP client. * It retrieves function declarations from the client, filters out disabled tools, @@ -448,6 +507,15 @@ export async function discoverTools( continue; } + if (!hasValidTypes(funcDecl.parametersJsonSchema)) { + console.warn( + `Skipping tool '${funcDecl.name}' from MCP server '${mcpServerName}' ` + + `because it has missing types in its parameter schema. Please file an ` + + `issue with the owner of the MCP server.`, + ); + continue; + } + discoveredTools.push( new DiscoveredMCPTool( mcpCallableTool, From e3e76777535da2817b5fcac012456db29147059e Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Wed, 6 Aug 2025 13:45:54 -0700 Subject: [PATCH 021/107] Add integration test for maximum schema depth error handling (#5685) --- eslint.config.js | 1 + .../mcp_server_cyclic_schema.test.js | 206 ++++++++++++++++++ integration-tests/test-helper.js | 5 + packages/core/src/core/geminiChat.ts | 32 ++- 4 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 integration-tests/mcp_server_cyclic_schema.test.js diff --git a/eslint.config.js b/eslint.config.js index e639e689..f35d4f35 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -28,6 +28,7 @@ export default tseslint.config( // Global ignores ignores: [ 'node_modules/*', + '.integration-tests/**', 'eslint.config.js', 'packages/cli/dist/**', 'packages/core/dist/**', diff --git a/integration-tests/mcp_server_cyclic_schema.test.js b/integration-tests/mcp_server_cyclic_schema.test.js new file mode 100644 index 00000000..a78e0922 --- /dev/null +++ b/integration-tests/mcp_server_cyclic_schema.test.js @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * This test verifies we can match maximum schema depth errors from Gemini + * and then detect and warn about the potential tools that caused the error. + */ + +import { test, describe, before } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { TestRig } from './test-helper.js'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { writeFileSync, readFileSync } from 'fs'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +// Create a minimal MCP server that doesn't require external dependencies +// This implements the MCP protocol directly using Node.js built-ins +const serverScript = `#!/usr/bin/env node +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const readline = require('readline'); +const fs = require('fs'); + +// Debug logging to stderr (only when MCP_DEBUG or VERBOSE is set) +const debugEnabled = process.env.MCP_DEBUG === 'true' || process.env.VERBOSE === 'true'; +function debug(msg) { + if (debugEnabled) { + fs.writeSync(2, \`[MCP-DEBUG] \${msg}\\n\`); + } +} + +debug('MCP server starting...'); + +// Simple JSON-RPC implementation for MCP +class SimpleJSONRPC { + constructor() { + this.handlers = new Map(); + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false + }); + + this.rl.on('line', (line) => { + debug(\`Received line: \${line}\`); + try { + const message = JSON.parse(line); + debug(\`Parsed message: \${JSON.stringify(message)}\`); + this.handleMessage(message); + } catch (e) { + debug(\`Parse error: \${e.message}\`); + } + }); + } + + send(message) { + const msgStr = JSON.stringify(message); + debug(\`Sending message: \${msgStr}\`); + process.stdout.write(msgStr + '\\n'); + } + + async handleMessage(message) { + if (message.method && this.handlers.has(message.method)) { + try { + const result = await this.handlers.get(message.method)(message.params || {}); + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + result + }); + } + } catch (error) { + if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32603, + message: error.message + } + }); + } + } + } else if (message.id !== undefined) { + this.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32601, + message: 'Method not found' + } + }); + } + } + + on(method, handler) { + this.handlers.set(method, handler); + } +} + +// Create MCP server +const rpc = new SimpleJSONRPC(); + +// Handle initialize +rpc.on('initialize', async (params) => { + debug('Handling initialize request'); + return { + protocolVersion: '2024-11-05', + capabilities: { + tools: {} + }, + serverInfo: { + name: 'cyclic-schema-server', + version: '1.0.0' + } + }; +}); + +// Handle tools/list +rpc.on('tools/list', async () => { + debug('Handling tools/list request'); + return { + tools: [{ + name: 'tool_with_cyclic_schema', + inputSchema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + child: { $ref: '#/properties/data/items' }, + }, + }, + }, + }, + } + }] + }; +}); + +// Send initialization notification +rpc.send({ + jsonrpc: '2.0', + method: 'initialized' +}); +`; + +describe('mcp server with cyclic tool schema is detected', () => { + const rig = new TestRig(); + + before(async () => { + // Setup test directory with MCP server configuration + await rig.setup('cyclic-schema-mcp-server', { + settings: { + mcpServers: { + 'cyclic-schema-server': { + command: 'node', + args: ['mcp-server.cjs'], + }, + }, + }, + }); + + // Create server script in the test directory + const testServerPath = join(rig.testDir, 'mcp-server.cjs'); + writeFileSync(testServerPath, serverScript); + + // Make the script executable (though running with 'node' should work anyway) + if (process.platform !== 'win32') { + const { chmodSync } = await import('fs'); + chmodSync(testServerPath, 0o755); + } + }); + + test('should error and suggest disabling the cyclic tool', async () => { + // Just run any command to trigger the schema depth error. + // If this test starts failing, check `isSchemaDepthError` from + // geminiChat.ts to see if it needs to be updated. + // Or, possibly it could mean that gemini has fixed the issue. + const output = await rig.run('hello'); + + // The error message is in a log file, so we need to extract the path and read it. + const match = output.match(/Full report available at: (.*\.json)/); + assert(match, `Could not find log file path in output: ${output}`); + + const logFilePath = match[1]; + const logFileContent = readFileSync(logFilePath, 'utf-8'); + + assert.match( + logFileContent, + / - tool_with_cyclic_schema \(cyclic-schema-server MCP Server\)/, + ); + }); +}); diff --git a/integration-tests/test-helper.js b/integration-tests/test-helper.js index 9526ea5f..e4d55631 100644 --- a/integration-tests/test-helper.js +++ b/integration-tests/test-helper.js @@ -258,6 +258,11 @@ export class TestRig { result = filteredLines.join('\n'); } + // If we have stderr output, include that also + if (stderr) { + result += `\n\nStdErr:\n${stderr}`; + } + resolve(result); } else { reject(new Error(`Process exited with code ${code}:\n${stderr}`)); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index c0e41b5e..5f5b22e8 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -300,16 +300,14 @@ export class GeminiChat { }; response = await retryWithBackoff(apiCall, { - shouldRetry: (error: Error) => { - // Check for likely cyclic schema errors, don't retry those. - if (error.message.includes('maximum schema depth exceeded')) - return false; - // Check error messages for status codes, or specific error names if known - if (error && error.message) { + shouldRetry: (error: unknown) => { + // Check for known error messages and codes. + if (error instanceof Error && error.message) { + if (isSchemaDepthError(error.message)) return false; if (error.message.includes('429')) return true; if (error.message.match(/5\d{2}/)) return true; } - return false; + return false; // Don't retry other errors by default }, onPersistent429: async (authType?: string, error?: unknown) => await this.handleFlashFallback(authType, error), @@ -419,12 +417,10 @@ export class GeminiChat { // the stream. For simple 429/500 errors on initial call, this is fine. // If errors occur mid-stream, this setup won't resume the stream; it will restart it. const streamResponse = await retryWithBackoff(apiCall, { - shouldRetry: (error: Error) => { - // Check for likely cyclic schema errors, don't retry those. - if (error.message.includes('maximum schema depth exceeded')) - return false; - // Check error messages for status codes, or specific error names if known - if (error && error.message) { + shouldRetry: (error: unknown) => { + // Check for known error messages and codes. + if (error instanceof Error && error.message) { + if (isSchemaDepthError(error.message)) return false; if (error.message.includes('429')) return true; if (error.message.match(/5\d{2}/)) return true; } @@ -689,10 +685,7 @@ export class GeminiChat { private async maybeIncludeSchemaDepthContext(error: unknown): Promise { // Check for potentially problematic cyclic tools with cyclic schemas // and include a recommendation to remove potentially problematic tools. - if ( - isStructuredError(error) && - error.message.includes('maximum schema depth exceeded') - ) { + if (isStructuredError(error) && isSchemaDepthError(error.message)) { const tools = (await this.config.getToolRegistry()).getAllTools(); const cyclicSchemaTools: string[] = []; for (const tool of tools) { @@ -714,3 +707,8 @@ export class GeminiChat { } } } + +/** Visible for Testing */ +export function isSchemaDepthError(errorMessage: string): boolean { + return errorMessage.includes('maximum schema depth exceeded'); +} From ad5d2af4e34fd23391bb6a0270cc320a0e56ba88 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Wed, 6 Aug 2025 16:46:50 -0400 Subject: [PATCH 022/107] tests: fix e2e tests (#5706) --- integration-tests/test-helper.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/test-helper.js b/integration-tests/test-helper.js index e4d55631..d1125a78 100644 --- a/integration-tests/test-helper.js +++ b/integration-tests/test-helper.js @@ -169,13 +169,13 @@ export class TestRig { }; if (typeof promptOrOptions === 'string') { - command += ` --prompt "${promptOrOptions}"`; + command += ` --prompt ${JSON.stringify(promptOrOptions)}`; } else if ( typeof promptOrOptions === 'object' && promptOrOptions !== null ) { if (promptOrOptions.prompt) { - command += ` --prompt "${promptOrOptions.prompt}"`; + command += ` --prompt ${JSON.stringify(promptOrOptions.prompt)}`; } if (promptOrOptions.stdin) { execOptions.input = promptOrOptions.stdin; From 43510ed212ea29b7bd752277de525f7821551b22 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Wed, 6 Aug 2025 13:52:04 -0700 Subject: [PATCH 023/107] bug(core): Prompt engineering for truncated read_file. (#5161) --- packages/core/src/tools/read-file.test.ts | 27 +++++++++++------- packages/core/src/tools/read-file.ts | 24 ++++++++++++++-- .../core/src/tools/read-many-files.test.ts | 28 +++++++++++++++++++ packages/core/src/tools/read-many-files.ts | 12 +++++--- packages/core/src/utils/fileUtils.test.ts | 11 +------- packages/core/src/utils/fileUtils.ts | 11 ++------ 6 files changed, 77 insertions(+), 36 deletions(-) diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index bb9317fd..8c11afab 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -222,7 +222,7 @@ describe('ReadFileTool', () => { }); }); - it('should pass offset and limit to read a slice of a text file', async () => { + it('should return a structured message when a slice of a text file is read', async () => { const filePath = path.join(tempRootDir, 'paginated.txt'); const fileContent = Array.from( { length: 20 }, @@ -240,15 +240,22 @@ describe('ReadFileTool', () => { ToolResult >; - expect(await invocation.execute(abortSignal)).toEqual({ - llmContent: [ - '[File content truncated: showing lines 6-8 of 20 total lines. Use offset/limit parameters to view more.]', - 'Line 6', - 'Line 7', - 'Line 8', - ].join('\n'), - returnDisplay: 'Read lines 6-8 of 20 from paginated.txt', - }); + const result = await invocation.execute(abortSignal); + + const expectedLlmContent = ` +IMPORTANT: The file content has been truncated. +Status: Showing lines 6-8 of 20 total lines. +Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 8. + +--- FILE CONTENT (truncated) --- +Line 6 +Line 7 +Line 8`; + + expect(result.llmContent).toEqual(expectedLlmContent); + expect(result.returnDisplay).toBe( + 'Read lines 6-8 of 20 from paginated.txt', + ); }); describe('with .geminiignore', () => { diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 3a05da06..7ef9d2b5 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -14,7 +14,7 @@ import { ToolLocation, ToolResult, } from './tools.js'; -import { Type } from '@google/genai'; +import { PartUnion, Type } from '@google/genai'; import { processSingleFileContent, getSpecificMimeType, @@ -84,6 +84,24 @@ class ReadFileToolInvocation }; } + let llmContent: PartUnion; + if (result.isTruncated) { + const [start, end] = result.linesShown!; + const total = result.originalLineCount!; + const nextOffset = this.params.offset + ? this.params.offset + end - start + 1 + : end; + llmContent = ` +IMPORTANT: The file content has been truncated. +Status: Showing lines ${start}-${end} of ${total} total lines. +Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}. + +--- FILE CONTENT (truncated) --- +${result.llmContent}`; + } else { + llmContent = result.llmContent || ''; + } + const lines = typeof result.llmContent === 'string' ? result.llmContent.split('\n').length @@ -98,7 +116,7 @@ class ReadFileToolInvocation ); return { - llmContent: result.llmContent || '', + llmContent, returnDisplay: result.returnDisplay || '', }; } @@ -117,7 +135,7 @@ export class ReadFileTool extends BaseDeclarativeTool< super( ReadFileTool.Name, 'ReadFile', - 'Reads and returns the content of a specified file from the local filesystem. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges.', + `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 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges.`, Icon.FileSearch, { properties: { diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 6ddd2a08..4035a6b7 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -476,6 +476,34 @@ describe('ReadManyFilesTool', () => { fs.rmSync(tempDir1, { recursive: true, force: true }); fs.rmSync(tempDir2, { recursive: true, force: true }); }); + + it('should add a warning for truncated files', async () => { + createFile('file1.txt', 'Content1'); + // Create a file that will be "truncated" by making it long + const longContent = Array.from({ length: 2500 }, (_, i) => `L${i}`).join( + '\n', + ); + createFile('large-file.txt', longContent); + + const params = { paths: ['*.txt'] }; + const result = await tool.execute(params, new AbortController().signal); + const content = result.llmContent as string[]; + + const normalFileContent = content.find((c) => c.includes('file1.txt')); + const truncatedFileContent = content.find((c) => + c.includes('large-file.txt'), + ); + + expect(normalFileContent).not.toContain( + '[WARNING: This file was truncated.', + ); + expect(truncatedFileContent).toContain( + "[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]", + ); + // Check that the actual content is still there but truncated + expect(truncatedFileContent).toContain('L200'); + expect(truncatedFileContent).not.toContain('L2400'); + }); }); describe('Batch Processing', () => { diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index 1fa2e15c..a380ea91 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -524,11 +524,15 @@ Use this tool when the user's query implies needing the content of several files '{filePath}', filePath, ); - contentParts.push( - `${separator}\n\n${fileReadResult.llmContent}\n\n`, - ); + let fileContentForLlm = ''; + if (fileReadResult.isTruncated) { + fileContentForLlm += `[WARNING: This file was truncated. To view the full content, use the 'read_file' tool on this specific file.]\n\n`; + } + fileContentForLlm += fileReadResult.llmContent; + contentParts.push(`${separator}\n\n${fileContentForLlm}\n\n`); } else { - contentParts.push(fileReadResult.llmContent); // This is a Part for image/pdf + // This is a Part for image/pdf, which we don't add the separator to. + contentParts.push(fileReadResult.llmContent); } processedFilesRelativePaths.push(relativePathForDisplay); diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index fb6b6820..cfedfe27 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -420,10 +420,7 @@ describe('fileUtils', () => { ); // Read lines 6-10 const expectedContent = lines.slice(5, 10).join('\n'); - expect(result.llmContent).toContain(expectedContent); - expect(result.llmContent).toContain( - '[File content truncated: showing lines 6-10 of 20 total lines. Use offset/limit parameters to view more.]', - ); + expect(result.llmContent).toBe(expectedContent); expect(result.returnDisplay).toBe('Read lines 6-10 of 20 from test.txt'); expect(result.isTruncated).toBe(true); expect(result.originalLineCount).toBe(20); @@ -444,9 +441,6 @@ describe('fileUtils', () => { const expectedContent = lines.slice(10, 20).join('\n'); expect(result.llmContent).toContain(expectedContent); - expect(result.llmContent).toContain( - '[File content truncated: showing lines 11-20 of 20 total lines. Use offset/limit parameters to view more.]', - ); expect(result.returnDisplay).toBe('Read lines 11-20 of 20 from test.txt'); expect(result.isTruncated).toBe(true); // This is the key check for the bug expect(result.originalLineCount).toBe(20); @@ -489,9 +483,6 @@ describe('fileUtils', () => { longLine.substring(0, 2000) + '... [truncated]', ); expect(result.llmContent).toContain('Another short line'); - expect(result.llmContent).toContain( - '[File content partially truncated: some lines exceeded maximum length of 2000 characters.]', - ); expect(result.returnDisplay).toBe( 'Read all 3 lines from test.txt (some lines were shortened)', ); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index a153d205..30ab69c6 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -303,14 +303,7 @@ export async function processSingleFileContent( const contentRangeTruncated = startLine > 0 || endLine < originalLineCount; const isTruncated = contentRangeTruncated || linesWereTruncatedInLength; - - let llmTextContent = ''; - if (contentRangeTruncated) { - llmTextContent += `[File content truncated: showing lines ${actualStartLine + 1}-${endLine} of ${originalLineCount} total lines. Use offset/limit parameters to view more.]\n`; - } else if (linesWereTruncatedInLength) { - llmTextContent += `[File content partially truncated: some lines exceeded maximum length of ${MAX_LINE_LENGTH_TEXT_FILE} characters.]\n`; - } - llmTextContent += formattedLines.join('\n'); + const llmContent = formattedLines.join('\n'); // By default, return nothing to streamline the common case of a successful read_file. let returnDisplay = ''; @@ -326,7 +319,7 @@ export async function processSingleFileContent( } return { - llmContent: llmTextContent, + llmContent, returnDisplay, isTruncated, originalLineCount, From b55467c1dd3515b35607a2abfbdefaa79bf6a48f Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 6 Aug 2025 20:55:29 +0000 Subject: [PATCH 024/107] [ide-mode] Support rendering in-IDE diffs using the edit tool (#5618) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../messages/ToolConfirmationMessage.tsx | 2 - packages/core/src/tools/edit.test.ts | 60 ++++++++++++++++++- packages/core/src/tools/edit.ts | 20 +++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 8b7f93d1..b4fe4167 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -138,8 +138,6 @@ export const ToolConfirmationMessage: React.FC< value: ToolConfirmationOutcome.Cancel, }); } else { - // TODO(chrstnb): support edit tool in IDE mode. - options.push({ label: 'Modify with external editor', value: ToolConfirmationOutcome.ModifyWithEditor, diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 029d3a3c..3bfa023e 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -10,6 +10,8 @@ const mockEnsureCorrectEdit = vi.hoisted(() => vi.fn()); const mockGenerateJson = vi.hoisted(() => vi.fn()); const mockOpenDiff = vi.hoisted(() => vi.fn()); +import { IDEConnectionStatus } from '../ide/ide-client.js'; + vi.mock('../utils/editCorrector.js', () => ({ ensureCorrectEdit: mockEnsureCorrectEdit, })); @@ -26,7 +28,7 @@ vi.mock('../utils/editor.js', () => ({ import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; import { EditTool, EditToolParams } from './edit.js'; -import { FileDiff } from './tools.js'; +import { FileDiff, ToolConfirmationOutcome } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import path from 'path'; import fs from 'fs'; @@ -58,6 +60,9 @@ describe('EditTool', () => { getApprovalMode: vi.fn(), setApprovalMode: vi.fn(), getWorkspaceContext: () => createMockWorkspaceContext(rootDir), + getIdeClient: () => undefined, + getIdeMode: () => false, + getIdeModeFeature: () => false, // getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method // Add other properties/methods of Config if EditTool uses them // Minimal other methods to satisfy Config type if needed by EditTool constructor or other direct uses: @@ -796,4 +801,57 @@ describe('EditTool', () => { expect(error).toContain(rootDir); }); }); + + describe('IDE mode', () => { + const testFile = 'edit_me.txt'; + let filePath: string; + let ideClient: any; + + beforeEach(() => { + filePath = path.join(rootDir, testFile); + ideClient = { + openDiff: vi.fn(), + getConnectionStatus: vi.fn().mockReturnValue({ + status: IDEConnectionStatus.Connected, + }), + }; + (mockConfig as any).getIdeMode = () => true; + (mockConfig as any).getIdeModeFeature = () => true; + (mockConfig as any).getIdeClient = () => ideClient; + }); + + it('should call ideClient.openDiff and update params on confirmation', async () => { + const initialContent = 'some old content here'; + const newContent = 'some new content here'; + const modifiedContent = 'some modified content here'; + fs.writeFileSync(filePath, initialContent); + const params: EditToolParams = { + file_path: filePath, + old_string: 'old', + new_string: 'new', + }; + mockEnsureCorrectEdit.mockResolvedValueOnce({ + params: { ...params, old_string: 'old', new_string: 'new' }, + occurrences: 1, + }); + ideClient.openDiff.mockResolvedValueOnce({ + status: 'accepted', + content: modifiedContent, + }); + + const confirmation = await tool.shouldConfirmExecute( + params, + new AbortController().signal, + ); + + expect(ideClient.openDiff).toHaveBeenCalledWith(filePath, newContent); + + if (confirmation && 'onConfirm' in confirmation) { + await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce); + } + + expect(params.old_string).toBe(initialContent); + expect(params.new_string).toBe(modifiedContent); + }); + }); }); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 853ad4c1..43505182 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -27,6 +27,7 @@ import { ensureCorrectEdit } from '../utils/editCorrector.js'; import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; import { ReadFileTool } from './read-file.js'; import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js'; +import { IDEConnectionStatus } from '../ide/ide-client.js'; /** * Parameters for the Edit tool @@ -328,6 +329,14 @@ Expectation for required parameters: 'Proposed', DEFAULT_DIFF_OPTIONS, ); + const ideClient = this.config.getIdeClient(); + const ideConfirmation = + this.config.getIdeModeFeature() && + this.config.getIdeMode() && + ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected + ? ideClient.openDiff(params.file_path, editData.newContent) + : undefined; + const confirmationDetails: ToolEditConfirmationDetails = { type: 'edit', title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`, @@ -340,7 +349,18 @@ Expectation for required parameters: if (outcome === ToolConfirmationOutcome.ProceedAlways) { this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); } + + if (ideConfirmation) { + const result = await ideConfirmation; + if (result.status === 'accepted' && result.content) { + // TODO(chrstn): See https://github.com/google-gemini/gemini-cli/pull/5618#discussion_r2255413084 + // for info on a possible race condition where the file is modified on disk while being edited. + params.old_string = editData.currentContent ?? ''; + params.new_string = result.content; + } + } }, + ideConfirmation, }; return confirmationDetails; } From 5cd63a6abc0531ec5e6781b2fa065cd22a64eede Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Wed, 6 Aug 2025 16:56:06 -0400 Subject: [PATCH 025/107] feat(cli): get the run-gemini-cli version from the GitHub API (#5708) --- .../ui/commands/setupGithubCommand.test.ts | 59 ++++----- .../cli/src/ui/commands/setupGithubCommand.ts | 78 +++++++++--- packages/cli/src/utils/gitUtils.test.ts | 115 ++++++++++++++++++ packages/cli/src/utils/gitUtils.ts | 77 +++++++++++- 4 files changed, 272 insertions(+), 57 deletions(-) create mode 100644 packages/cli/src/utils/gitUtils.test.ts diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index ae6378c7..6417c60a 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -5,13 +5,22 @@ */ import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; -import * as child_process from 'child_process'; +import * as gitUtils from '../../utils/gitUtils.js'; import { setupGithubCommand } from './setupGithubCommand.js'; import { CommandContext, ToolActionReturn } from './types.js'; vi.mock('child_process'); -describe('setupGithubCommand', () => { +// Mock fetch globally +global.fetch = vi.fn(); + +vi.mock('../../utils/gitUtils.js', () => ({ + isGitHubRepository: vi.fn(), + getGitRepoRoot: vi.fn(), + getLatestGitHubRelease: vi.fn(), +})); + +describe('setupGithubCommand', async () => { beforeEach(() => { vi.resetAllMocks(); }); @@ -20,49 +29,35 @@ describe('setupGithubCommand', () => { vi.restoreAllMocks(); }); - it('returns a tool action to download github workflows and handles paths', () => { + it('returns a tool action to download github workflows and handles paths', async () => { const fakeRepoRoot = '/github.com/fake/repo/root'; - vi.mocked(child_process.execSync).mockReturnValue(fakeRepoRoot); + const fakeReleaseVersion = 'v1.2.3'; - const result = setupGithubCommand.action?.( + vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); + vi.mocked(gitUtils.getGitRepoRoot).mockReturnValueOnce(fakeRepoRoot); + vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( + fakeReleaseVersion, + ); + + const result = (await setupGithubCommand.action?.( {} as CommandContext, '', - ) as ToolActionReturn; - - expect(result.type).toBe('tool'); - expect(result.toolName).toBe('run_shell_command'); - expect(child_process.execSync).toHaveBeenCalledWith( - 'git rev-parse --show-toplevel', - { - encoding: 'utf-8', - }, - ); - expect(child_process.execSync).toHaveBeenCalledWith('git remote -v', { - encoding: 'utf-8', - }); + )) as ToolActionReturn; const { command } = result.toolArgs; const expectedSubstrings = [ + `set -eEuo pipefail`, `mkdir -p "${fakeRepoRoot}/.github/workflows"`, - `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-cli.yml"`, - `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-automated-triage.yml"`, - `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-issue-scheduled-triage.yml"`, - `curl -fsSL -o "${fakeRepoRoot}/.github/workflows/gemini-pr-review.yml"`, - 'https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/v0/examples/workflows/', + `curl --fail --location --output "/github.com/fake/repo/root/.github/workflows/gemini-cli.yml" --show-error --silent`, + `curl --fail --location --output "/github.com/fake/repo/root/.github/workflows/gemini-issue-automated-triage.yml" --show-error --silent`, + `curl --fail --location --output "/github.com/fake/repo/root/.github/workflows/gemini-issue-scheduled-triage.yml" --show-error --silent`, + `curl --fail --location --output "/github.com/fake/repo/root/.github/workflows/gemini-pr-review.yml" --show-error --silent`, + `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/`, ]; for (const substring of expectedSubstrings) { expect(command).toContain(substring); } }); - - it('throws an error if git root cannot be determined', () => { - vi.mocked(child_process.execSync).mockReturnValue(''); - expect(() => { - setupGithubCommand.action?.({} as CommandContext, ''); - }).toThrow( - 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', - ); - }); }); diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 047e11eb..1b5b3277 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -5,8 +5,13 @@ */ import path from 'path'; -import { execSync } from 'child_process'; -import { isGitHubRepository } from '../../utils/gitUtils.js'; + +import { CommandContext } from '../../ui/commands/types.js'; +import { + getGitRepoRoot, + getLatestGitHubRelease, + isGitHubRepository, +} from '../../utils/gitUtils.js'; import { CommandKind, @@ -18,26 +23,29 @@ export const setupGithubCommand: SlashCommand = { name: 'setup-github', description: 'Set up GitHub Actions', kind: CommandKind.BUILT_IN, - action: (): SlashCommandActionReturn => { + action: async ( + context: CommandContext, + ): Promise => { if (!isGitHubRepository()) { throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', ); } - let gitRootRepo: string; + // Find the root directory of the repo + let gitRepoRoot: string; try { - gitRootRepo = execSync('git rev-parse --show-toplevel', { - encoding: 'utf-8', - }).trim(); - } catch { + gitRepoRoot = getGitRepoRoot(); + } catch (_error) { + console.debug(`Failed to get git repo root:`, _error); throw new Error( 'Unable to determine the GitHub repository. /setup-github must be run from a git repository.', ); } - const version = 'v0'; - const workflowBaseUrl = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${version}/examples/workflows/`; + // Get the latest release tag from GitHub + const proxy = context?.services?.config?.getProxy(); + const releaseTag = await getLatestGitHubRelease(proxy); const workflows = [ 'gemini-cli/gemini-cli.yml', @@ -46,16 +54,29 @@ export const setupGithubCommand: SlashCommand = { 'pr-review/gemini-pr-review.yml', ]; - const command = [ - 'set -e', - `mkdir -p "${gitRootRepo}/.github/workflows"`, - ...workflows.map((workflow) => { - const fileName = path.basename(workflow); - return `curl -fsSL -o "${gitRootRepo}/.github/workflows/${fileName}" "${workflowBaseUrl}/${workflow}"`; - }), - 'echo "Workflows downloaded successfully. Follow steps in https://github.com/google-github-actions/run-gemini-cli/blob/v0/README.md#quick-start (skipping the /setup-github step) to complete setup."', - 'open https://github.com/google-github-actions/run-gemini-cli/blob/v0/README.md#quick-start', - ].join(' && '); + const commands = []; + + // Ensure fast exit + commands.push(`set -eEuo pipefail`); + + // Make the directory if it doesn't exist + commands.push(`mkdir -p "${gitRepoRoot}/.github/workflows"`); + + for (const workflow of workflows) { + const fileName = path.basename(workflow); + const curlCommand = buildCurlCommand( + `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`, + [`--output "${gitRepoRoot}/.github/workflows/${fileName}"`], + ); + commands.push(curlCommand); + } + + commands.push( + `echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start (skipping the /setup-github step) to complete setup."`, + `open https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`, + ); + + const command = `(${commands.join(' && ')})`; return { type: 'tool', toolName: 'run_shell_command', @@ -67,3 +88,20 @@ export const setupGithubCommand: SlashCommand = { }; }, }; + +// buildCurlCommand is a helper for constructing a consistent curl command. +function buildCurlCommand(u: string, additionalArgs?: string[]): string { + const args = []; + args.push('--fail'); + args.push('--location'); + args.push('--show-error'); + args.push('--silent'); + + for (const val of additionalArgs || []) { + args.push(val); + } + + args.sort(); + + return `curl ${args.join(' ')} "${u}"`; +} diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts new file mode 100644 index 00000000..4a29f589 --- /dev/null +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; +import * as child_process from 'child_process'; +import { + isGitHubRepository, + getGitRepoRoot, + getLatestGitHubRelease, +} from './gitUtils.js'; + +vi.mock('child_process'); + +describe('isGitHubRepository', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false if the git command fails', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(isGitHubRepository()).toBe(false); + }); + + it('returns false if the remote is not github.com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce('https://gitlab.com'); + expect(isGitHubRepository()).toBe(false); + }); + + it('returns true if the remote is github.com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(` + origin https://github.com/sethvargo/gemini-cli (fetch) + origin https://github.com/sethvargo/gemini-cli (push) + `); + expect(isGitHubRepository()).toBe(true); + }); +}); + +describe('getGitRepoRoot', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if git root cannot be determined', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(() => { + getGitRepoRoot(); + }).toThrowError(/oops/); + }); + + it('throws an error if git root is empty', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(''); + expect(() => { + getGitRepoRoot(); + }).toThrowError(/Git repo returned empty value/); + }); + + it('returns the root', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce('/path/to/git/repo'); + expect(getGitRepoRoot()).toBe('/path/to/git/repo'); + }); +}); + +describe('getLatestRelease', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if the fetch fails', async () => { + global.fetch = vi.fn(() => Promise.reject('nope')); + expect(getLatestGitHubRelease()).rejects.toThrowError( + /Unable to determine the latest/, + ); + }); + + it('throws an error if the fetch does not return a json body', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ foo: 'bar' }), + } as Response), + ); + expect(getLatestGitHubRelease()).rejects.toThrowError( + /Unable to determine the latest/, + ); + }); + + it('returns the release version', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: 'v1.2.3' }), + } as Response), + ); + expect(getLatestGitHubRelease()).resolves.toBe('v1.2.3'); + }); +}); diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index d510008c..30ca2245 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -5,22 +5,89 @@ */ import { execSync } from 'child_process'; +import { ProxyAgent, setGlobalDispatcher } from 'undici'; /** * Checks if a directory is within a git repository hosted on GitHub. * @returns true if the directory is in a git repository with a github.com remote, false otherwise */ -export function isGitHubRepository(): boolean { +export const isGitHubRepository = (): boolean => { try { - const remotes = execSync('git remote -v', { - encoding: 'utf-8', - }); + const remotes = ( + execSync('git remote -v', { + encoding: 'utf-8', + }) || '' + ).trim(); const pattern = /github\.com/; return pattern.test(remotes); } catch (_error) { // If any filesystem error occurs, assume not a git repo + console.debug(`Failed to get git remote:`, _error); return false; } -} +}; + +/** + * getGitRepoRoot returns the root directory of the git repository. + * @returns the path to the root of the git repo. + * @throws error if the exec command fails. + */ +export const getGitRepoRoot = (): string => { + const gitRepoRoot = ( + execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8', + }) || '' + ).trim(); + + if (!gitRepoRoot) { + throw new Error(`Git repo returned empty value`); + } + + return gitRepoRoot; +}; + +/** + * getLatestGitHubRelease returns the release tag as a string. + * @returns string of the release tag (e.g. "v1.2.3"). + */ +export const getLatestGitHubRelease = async ( + proxy?: string, +): Promise => { + try { + const controller = new AbortController(); + if (proxy) { + setGlobalDispatcher(new ProxyAgent(proxy)); + } + + const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`; + + const response = await fetch(endpoint, { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error( + `Invalid response code: ${response.status} - ${response.statusText}`, + ); + } + + const releaseTag = (await response.json()).tag_name; + if (!releaseTag) { + throw new Error(`Response did not include tag_name field`); + } + return releaseTag; + } catch (_error) { + console.debug(`Failed to determine latest run-gemini-cli release:`, _error); + throw new Error( + `Unable to determine the latest run-gemini-cli release on GitHub.`, + ); + } +}; From 626844b539af6bf6d21a04d43173074c98b71474 Mon Sep 17 00:00:00 2001 From: shrutip90 Date: Wed, 6 Aug 2025 15:27:21 -0700 Subject: [PATCH 026/107] experiment: Add feature exp flag for folder trust (#5709) --- packages/cli/src/config/config.test.ts | 33 ++++++++++++++++++++++++++ packages/cli/src/config/config.ts | 3 +++ packages/cli/src/config/settings.ts | 1 + packages/core/src/config/config.ts | 7 ++++++ 4 files changed, 44 insertions(+) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 64ecdbb8..6a7e3b57 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1009,6 +1009,39 @@ describe('loadCliConfig ideModeFeature', () => { }); }); +describe('loadCliConfig folderTrustFeature', () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should be false by default', async () => { + process.argv = ['node', 'script.js']; + const settings: Settings = {}; + const argv = await parseArguments(); + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getFolderTrustFeature()).toBe(false); + }); + + it('should be true when settings.folderTrustFeature is true', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { folderTrustFeature: true }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getFolderTrustFeature()).toBe(true); + }); +}); + vi.mock('fs', async () => { const actualFs = await vi.importActual('fs'); const MOCK_CWD1 = process.cwd(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7175c033..2c942c08 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -313,6 +313,8 @@ export async function loadCliConfig( const ideModeFeature = argv.ideModeFeature ?? settings.ideModeFeature ?? false; + const folderTrustFeature = settings.folderTrustFeature ?? false; + const allExtensions = annotateActiveExtensions( extensions, argv.extensions || [], @@ -480,6 +482,7 @@ export async function loadCliConfig( summarizeToolOutput: settings.summarizeToolOutput, ideMode, ideModeFeature, + folderTrustFeature, }); } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 93641ae0..64500845 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -112,6 +112,7 @@ export interface Settings { // Flag to be removed post-launch. ideModeFeature?: boolean; + folderTrustFeature?: boolean; /// IDE mode setting configured via slash command toggle. ideMode?: boolean; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fa51a6af..005573da 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -188,6 +188,7 @@ export interface ConfigParameters { noBrowser?: boolean; summarizeToolOutput?: Record; ideModeFeature?: boolean; + folderTrustFeature?: boolean; ideMode?: boolean; loadMemoryFromIncludeDirectories?: boolean; } @@ -233,6 +234,7 @@ export class Config { private readonly extensionContextFilePaths: string[]; private readonly noBrowser: boolean; private readonly ideModeFeature: boolean; + private readonly folderTrustFeature: boolean; private ideMode: boolean; private ideClient: IdeClient; private inFallbackMode = false; @@ -305,6 +307,7 @@ export class Config { this.noBrowser = params.noBrowser ?? false; this.summarizeToolOutput = params.summarizeToolOutput; this.ideModeFeature = params.ideModeFeature ?? false; + this.folderTrustFeature = params.folderTrustFeature ?? false; this.ideMode = params.ideMode ?? false; this.ideClient = IdeClient.getInstance(); if (this.ideMode && this.ideModeFeature) { @@ -638,6 +641,10 @@ export class Config { return this.ideModeFeature; } + getFolderTrustFeature(): boolean { + return this.folderTrustFeature; + } + getIdeMode(): boolean { return this.ideMode; } From 4782113cebc990b54353e095db1eb6c5e654bdef Mon Sep 17 00:00:00 2001 From: Richie Foreman Date: Wed, 6 Aug 2025 19:31:42 -0400 Subject: [PATCH 027/107] =?UTF-8?q?fix(core):=20Improve=20errors=20in=20si?= =?UTF-8?q?tuations=20where=20the=20command=20spawn=20does=20=E2=80=A6=20(?= =?UTF-8?q?#5723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/shellExecutionService.test.ts | 10 +++++ .../src/services/shellExecutionService.ts | 45 ++++++++++++++----- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index cfce08d2..2fe51a5e 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -185,6 +185,16 @@ describe('ShellExecutionService', () => { expect(result.error).toBe(spawnError); expect(result.exitCode).toBe(1); }); + + it('handles errors that do not fire the exit event', async () => { + const error = new Error('spawn abc ENOENT'); + const { result } = await simulateExecution('touch cat.jpg', (cp) => { + cp.emit('error', error); // No exit event is fired. + }); + + expect(result.error).toBe(error); + expect(result.exitCode).toBe(1); + }); }); describe('Aborting Commands', () => { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index d1126a7d..3749fcf6 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -174,7 +174,19 @@ export class ShellExecutionService { child.stdout.on('data', (data) => handleOutput(data, 'stdout')); child.stderr.on('data', (data) => handleOutput(data, 'stderr')); child.on('error', (err) => { + const { stdout, stderr, finalBuffer } = cleanup(); error = err; + resolve({ + error, + stdout, + stderr, + rawOutput: finalBuffer, + output: stdout + (stderr ? `\n${stderr}` : ''), + exitCode: 1, + signal: null, + aborted: false, + pid: child.pid, + }); }); const abortHandler = async () => { @@ -200,18 +212,8 @@ export class ShellExecutionService { abortSignal.addEventListener('abort', abortHandler, { once: true }); - child.on('exit', (code, signal) => { - exited = true; - abortSignal.removeEventListener('abort', abortHandler); - - if (stdoutDecoder) { - stdout += stripAnsi(stdoutDecoder.decode()); - } - if (stderrDecoder) { - stderr += stripAnsi(stderrDecoder.decode()); - } - - const finalBuffer = Buffer.concat(outputChunks); + child.on('exit', (code: number, signal: NodeJS.Signals) => { + const { stdout, stderr, finalBuffer } = cleanup(); resolve({ rawOutput: finalBuffer, @@ -225,6 +227,25 @@ export class ShellExecutionService { pid: child.pid, }); }); + + /** + * Cleans up a process (and it's accompanying state) that is exiting or + * erroring and returns output formatted output buffers and strings + */ + function cleanup() { + exited = true; + abortSignal.removeEventListener('abort', abortHandler); + if (stdoutDecoder) { + stdout += stripAnsi(stdoutDecoder.decode()); + } + if (stderrDecoder) { + stderr += stripAnsi(stderrDecoder.decode()); + } + + const finalBuffer = Buffer.concat(outputChunks); + + return { stdout, stderr, finalBuffer }; + } }); return { pid: child.pid, result }; From 9ac3e8b79ecc584805c27d3602612c30f2adee80 Mon Sep 17 00:00:00 2001 From: DevMassive <76215222+DevMassive@users.noreply.github.com> Date: Thu, 7 Aug 2025 08:41:04 +0900 Subject: [PATCH 028/107] feat: Improve @-command file path completion with fzf integration (#5650) Co-authored-by: Jacob Richman --- package-lock.json | 7 ++++++ .../cli/src/ui/hooks/useAtCompletion.test.ts | 2 +- packages/core/package.json | 1 + .../src/utils/filesearch/fileSearch.test.ts | 24 +++++++++++++++++++ .../core/src/utils/filesearch/fileSearch.ts | 17 ++++++++++++- 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index b16c4904..1e5e4211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5540,6 +5540,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fzf": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz", + "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==", + "license": "BSD-3-Clause" + }, "node_modules/gcp-metadata": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", @@ -11889,6 +11895,7 @@ "diff": "^7.0.0", "dotenv": "^17.1.0", "fdir": "^6.4.6", + "fzf": "^0.5.2", "glob": "^10.4.5", "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 43289992..aa198fc1 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -114,8 +114,8 @@ describe('useAtCompletion', () => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'src/', 'src/components/', - 'src/components/Button.tsx', 'src/index.js', + 'src/components/Button.tsx', ]); }); diff --git a/packages/core/package.json b/packages/core/package.json index 6e42a4a9..37e3687d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,6 +35,7 @@ "diff": "^7.0.0", "dotenv": "^17.1.0", "fdir": "^6.4.6", + "fzf": "^0.5.2", "glob": "^10.4.5", "google-auth-library": "^9.11.0", "html-to-text": "^9.0.5", diff --git a/packages/core/src/utils/filesearch/fileSearch.test.ts b/packages/core/src/utils/filesearch/fileSearch.test.ts index a7f59f91..38657492 100644 --- a/packages/core/src/utils/filesearch/fileSearch.test.ts +++ b/packages/core/src/utils/filesearch/fileSearch.test.ts @@ -290,6 +290,30 @@ describe('FileSearch', () => { expect(results).toEqual(['src/file1.js', 'src/file2.js']); // Assuming alphabetical sort }); + it('should use fzf for fuzzy matching when pattern does not contain wildcards', async () => { + tmpDir = await createTmpDir({ + src: { + 'main.js': '', + 'util.ts': '', + 'style.css': '', + }, + }); + + const fileSearch = new FileSearch({ + projectRoot: tmpDir, + useGitignore: false, + useGeminiignore: false, + ignoreDirs: [], + cache: false, + cacheTtl: 0, + }); + + await fileSearch.initialize(); + const results = await fileSearch.search('sst'); + + expect(results).toEqual(['src/style.css']); + }); + it('should return empty array when no matches are found', async () => { tmpDir = await createTmpDir({ src: ['file1.js'], diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index db14bc65..76a099f7 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -11,6 +11,7 @@ import picomatch from 'picomatch'; import { Ignore } from './ignore.js'; import { ResultCache } from './result-cache.js'; import * as cache from './crawlCache.js'; +import { Fzf, FzfResultItem } from 'fzf'; export type FileSearchOptions = { projectRoot: string; @@ -77,6 +78,18 @@ export async function filter( return results; } +/** + * Filters a list of paths based on a given pattern using fzf. + * @param allPaths The list of all paths to filter. + * @param pattern The fzf pattern to filter by. + * @returns The filtered and sorted list of paths. + */ +function filterByFzf(allPaths: string[], pattern: string) { + return new Fzf(allPaths) + .find(pattern) + .map((entry: FzfResultItem) => entry.item); +} + export type SearchOptions = { signal?: AbortSignal; maxResults?: number; @@ -137,7 +150,9 @@ export class FileSearch { filteredCandidates = candidates; } else { // Apply the user's picomatch pattern filter - filteredCandidates = await filter(candidates, pattern, options.signal); + filteredCandidates = pattern.includes('*') + ? await filter(candidates, pattern, options.signal) + : filterByFzf(this.allFiles, pattern); this.resultCache!.set(pattern, filteredCandidates); } From 01f7c4b7406cd9ce94667dcbd5e586dad9573d67 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Wed, 6 Aug 2025 16:59:50 -0700 Subject: [PATCH 029/107] Fix(tests): update mcp_server_cyclic_schema test (#5733) --- integration-tests/mcp_server_cyclic_schema.test.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/integration-tests/mcp_server_cyclic_schema.test.js b/integration-tests/mcp_server_cyclic_schema.test.js index a78e0922..1ace98f1 100644 --- a/integration-tests/mcp_server_cyclic_schema.test.js +++ b/integration-tests/mcp_server_cyclic_schema.test.js @@ -14,7 +14,7 @@ import { strict as assert } from 'node:assert'; import { TestRig } from './test-helper.js'; import { join } from 'path'; import { fileURLToPath } from 'url'; -import { writeFileSync, readFileSync } from 'fs'; +import { writeFileSync } from 'fs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -191,16 +191,9 @@ describe('mcp server with cyclic tool schema is detected', () => { // Or, possibly it could mean that gemini has fixed the issue. const output = await rig.run('hello'); - // The error message is in a log file, so we need to extract the path and read it. - const match = output.match(/Full report available at: (.*\.json)/); - assert(match, `Could not find log file path in output: ${output}`); - - const logFilePath = match[1]; - const logFileContent = readFileSync(logFilePath, 'utf-8'); - assert.match( - logFileContent, - / - tool_with_cyclic_schema \(cyclic-schema-server MCP Server\)/, + output, + /Skipping tool 'tool_with_cyclic_schema' from MCP server 'cyclic-schema-server' because it has missing types in its parameter schema/, ); }); }); From 99f88851fb86b3bdb8408891391f8b733d7e0053 Mon Sep 17 00:00:00 2001 From: anthony bushong Date: Wed, 6 Aug 2025 17:01:22 -0700 Subject: [PATCH 030/107] fix(actions): swap gha bot for cla allowlisted gemini-cli-robot (#5730) --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c449702..a011f776 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,8 +99,8 @@ jobs: - name: Configure Git User run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "gemini-cli-robot" + git config user.email "gemini-cli-robot@google.com" - name: Create and switch to a release branch id: release_branch From d6a7334279366762787bed6a5bd08a125c7c3ba8 Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Wed, 6 Aug 2025 17:19:10 -0700 Subject: [PATCH 031/107] fix(logging): Ensure sandbox startup messages are routed to stderr (#5725) --- packages/cli/src/ui/types.ts | 2 +- packages/cli/src/ui/utils/ConsolePatcher.ts | 5 +- packages/cli/src/utils/sandbox.ts | 1078 ++++++++++--------- 3 files changed, 551 insertions(+), 534 deletions(-) diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 6d078b22..b52bf64d 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -224,7 +224,7 @@ export type Message = }; export interface ConsoleMessageItem { - type: 'log' | 'warn' | 'error' | 'debug'; + type: 'log' | 'warn' | 'error' | 'debug' | 'info'; content: string; count: number; } diff --git a/packages/cli/src/ui/utils/ConsolePatcher.ts b/packages/cli/src/ui/utils/ConsolePatcher.ts index a429698d..8e95adc1 100644 --- a/packages/cli/src/ui/utils/ConsolePatcher.ts +++ b/packages/cli/src/ui/utils/ConsolePatcher.ts @@ -18,6 +18,7 @@ export class ConsolePatcher { private originalConsoleWarn = console.warn; private originalConsoleError = console.error; private originalConsoleDebug = console.debug; + private originalConsoleInfo = console.info; private params: ConsolePatcherParams; @@ -30,6 +31,7 @@ export class ConsolePatcher { console.warn = this.patchConsoleMethod('warn', this.originalConsoleWarn); console.error = this.patchConsoleMethod('error', this.originalConsoleError); console.debug = this.patchConsoleMethod('debug', this.originalConsoleDebug); + console.info = this.patchConsoleMethod('info', this.originalConsoleInfo); } cleanup = () => { @@ -37,13 +39,14 @@ export class ConsolePatcher { console.warn = this.originalConsoleWarn; console.error = this.originalConsoleError; console.debug = this.originalConsoleDebug; + console.info = this.originalConsoleInfo; }; private formatArgs = (args: unknown[]): string => util.format(...args); private patchConsoleMethod = ( - type: 'log' | 'warn' | 'error' | 'debug', + type: 'log' | 'warn' | 'error' | 'debug' | 'info', originalMethod: (...args: unknown[]) => void, ) => (...args: unknown[]) => { diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index d53608d1..3550f45b 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -16,6 +16,7 @@ import { } from '../config/settings.js'; import { promisify } from 'util'; import { Config, SandboxConfig } from '@google/gemini-cli-core'; +import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; const execAsync = promisify(exec); @@ -185,119 +186,564 @@ export async function start_sandbox( nodeArgs: string[] = [], cliConfig?: Config, ) { - if (config.command === 'sandbox-exec') { - // disallow BUILD_SANDBOX + const patcher = new ConsolePatcher({ + debugMode: cliConfig?.getDebugMode() || !!process.env.DEBUG, + stderr: true, + }); + patcher.patch(); + + try { + if (config.command === 'sandbox-exec') { + // disallow BUILD_SANDBOX + if (process.env.BUILD_SANDBOX) { + console.error('ERROR: cannot BUILD_SANDBOX when using macOS Seatbelt'); + process.exit(1); + } + const profile = (process.env.SEATBELT_PROFILE ??= 'permissive-open'); + let profileFile = new URL(`sandbox-macos-${profile}.sb`, import.meta.url) + .pathname; + // if profile name is not recognized, then look for file under project settings directory + if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { + profileFile = path.join( + SETTINGS_DIRECTORY_NAME, + `sandbox-macos-${profile}.sb`, + ); + } + if (!fs.existsSync(profileFile)) { + console.error( + `ERROR: missing macos seatbelt profile file '${profileFile}'`, + ); + process.exit(1); + } + // Log on STDERR so it doesn't clutter the output on STDOUT + console.error(`using macos seatbelt (profile: ${profile}) ...`); + // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS + const nodeOptions = [ + ...(process.env.DEBUG ? ['--inspect-brk'] : []), + ...nodeArgs, + ].join(' '); + + const args = [ + '-D', + `TARGET_DIR=${fs.realpathSync(process.cwd())}`, + '-D', + `TMP_DIR=${fs.realpathSync(os.tmpdir())}`, + '-D', + `HOME_DIR=${fs.realpathSync(os.homedir())}`, + '-D', + `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`, + ]; + + // Add included directories from the workspace context + // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them + const MAX_INCLUDE_DIRS = 5; + const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || ''); + const includedDirs: string[] = []; + + if (cliConfig) { + const workspaceContext = cliConfig.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + + // Filter out TARGET_DIR + for (const dir of directories) { + const realDir = fs.realpathSync(dir); + if (realDir !== targetDir) { + includedDirs.push(realDir); + } + } + } + + for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { + let dirPath = '/dev/null'; // Default to a safe path that won't cause issues + + if (i < includedDirs.length) { + dirPath = includedDirs[i]; + } + + args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); + } + + args.push( + '-f', + profileFile, + 'sh', + '-c', + [ + `SANDBOX=sandbox-exec`, + `NODE_OPTIONS="${nodeOptions}"`, + ...process.argv.map((arg) => quote([arg])), + ].join(' '), + ); + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + const sandboxEnv = { ...process.env }; + if (proxyCommand) { + const proxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + 'http://localhost:8877'; + sandboxEnv['HTTPS_PROXY'] = proxy; + sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl + sandboxEnv['HTTP_PROXY'] = proxy; + sandboxEnv['http_proxy'] = proxy; + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (noProxy) { + sandboxEnv['NO_PROXY'] = noProxy; + sandboxEnv['no_proxy'] = noProxy; + } + proxyProcess = spawn(proxyCommand, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + detached: true, + }); + // install handlers to stop proxy on exit/signal + const stopProxy = () => { + console.log('stopping proxy ...'); + if (proxyProcess?.pid) { + process.kill(-proxyProcess.pid, 'SIGTERM'); + } + }; + process.on('exit', stopProxy); + process.on('SIGINT', stopProxy); + process.on('SIGTERM', stopProxy); + + // commented out as it disrupts ink rendering + // proxyProcess.stdout?.on('data', (data) => { + // console.info(data.toString()); + // }); + proxyProcess.stderr?.on('data', (data) => { + console.error(data.toString()); + }); + proxyProcess.on('close', (code, signal) => { + console.error( + `ERROR: proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, + ); + if (sandboxProcess?.pid) { + process.kill(-sandboxProcess.pid, 'SIGTERM'); + } + process.exit(1); + }); + console.log('waiting for proxy to start ...'); + await execAsync( + `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, + ); + } + // spawn child and let it inherit stdio + sandboxProcess = spawn(config.command, args, { + stdio: 'inherit', + }); + await new Promise((resolve) => sandboxProcess?.on('close', resolve)); + return; + } + + console.error(`hopping into sandbox (command: ${config.command}) ...`); + + // determine full path for gemini-cli to distinguish linked vs installed setting + const gcPath = fs.realpathSync(process.argv[1]); + + const projectSandboxDockerfile = path.join( + SETTINGS_DIRECTORY_NAME, + 'sandbox.Dockerfile', + ); + const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); + + const image = config.image; + const workdir = path.resolve(process.cwd()); + const containerWorkdir = getContainerPath(workdir); + + // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo + // + // note this can only be done with binary linked from gemini-cli repo if (process.env.BUILD_SANDBOX) { - console.error('ERROR: cannot BUILD_SANDBOX when using macOS Seatbelt'); - process.exit(1); + if (!gcPath.includes('gemini-cli/packages/')) { + console.error( + 'ERROR: cannot build sandbox using installed gemini binary; ' + + 'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.', + ); + process.exit(1); + } else { + console.error('building sandbox ...'); + const gcRoot = gcPath.split('/packages/')[0]; + // if project folder has sandbox.Dockerfile under project settings folder, use that + let buildArgs = ''; + const projectSandboxDockerfile = path.join( + SETTINGS_DIRECTORY_NAME, + 'sandbox.Dockerfile', + ); + if (isCustomProjectSandbox) { + console.error(`using ${projectSandboxDockerfile} for sandbox`); + buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`; + } + execSync( + `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`, + { + stdio: 'inherit', + env: { + ...process.env, + GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) + }, + }, + ); + } } - const profile = (process.env.SEATBELT_PROFILE ??= 'permissive-open'); - let profileFile = new URL(`sandbox-macos-${profile}.sb`, import.meta.url) - .pathname; - // if profile name is not recognized, then look for file under project settings directory - if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { - profileFile = path.join( - SETTINGS_DIRECTORY_NAME, - `sandbox-macos-${profile}.sb`, - ); - } - if (!fs.existsSync(profileFile)) { + + // stop if image is missing + if (!(await ensureSandboxImageIsPresent(config.command, image))) { + const remedy = + image === LOCAL_DEV_SANDBOX_IMAGE_NAME + ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' + : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; console.error( - `ERROR: missing macos seatbelt profile file '${profileFile}'`, + `ERROR: Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, ); process.exit(1); } - // Log on STDERR so it doesn't clutter the output on STDOUT - console.error(`using macos seatbelt (profile: ${profile}) ...`); - // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS - const nodeOptions = [ - ...(process.env.DEBUG ? ['--inspect-brk'] : []), - ...nodeArgs, - ].join(' '); - const args = [ - '-D', - `TARGET_DIR=${fs.realpathSync(process.cwd())}`, - '-D', - `TMP_DIR=${fs.realpathSync(os.tmpdir())}`, - '-D', - `HOME_DIR=${fs.realpathSync(os.homedir())}`, - '-D', - `CACHE_DIR=${fs.realpathSync(execSync(`getconf DARWIN_USER_CACHE_DIR`).toString().trim())}`, - ]; + // use interactive mode and auto-remove container on exit + // run init binary inside container to forward signals & reap zombies + const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; - // Add included directories from the workspace context - // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them - const MAX_INCLUDE_DIRS = 5; - const targetDir = fs.realpathSync(cliConfig?.getTargetDir() || ''); - const includedDirs: string[] = []; + // add custom flags from SANDBOX_FLAGS + if (process.env.SANDBOX_FLAGS) { + const flags = parse(process.env.SANDBOX_FLAGS, process.env).filter( + (f): f is string => typeof f === 'string', + ); + args.push(...flags); + } - if (cliConfig) { - const workspaceContext = cliConfig.getWorkspaceContext(); - const directories = workspaceContext.getDirectories(); + // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container + if (process.stdin.isTTY) { + args.push('-t'); + } - // Filter out TARGET_DIR - for (const dir of directories) { - const realDir = fs.realpathSync(dir); - if (realDir !== targetDir) { - includedDirs.push(realDir); + // mount current directory as working directory in sandbox (set via --workdir) + args.push('--volume', `${workdir}:${containerWorkdir}`); + + // mount user settings directory inside container, after creating if missing + // note user/home changes inside sandbox and we mount at BOTH paths for consistency + const userSettingsDirOnHost = USER_SETTINGS_DIR; + const userSettingsDirInSandbox = getContainerPath( + `/home/node/${SETTINGS_DIRECTORY_NAME}`, + ); + if (!fs.existsSync(userSettingsDirOnHost)) { + fs.mkdirSync(userSettingsDirOnHost); + } + args.push( + '--volume', + `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`, + ); + if (userSettingsDirInSandbox !== userSettingsDirOnHost) { + args.push( + '--volume', + `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`, + ); + } + + // mount os.tmpdir() as os.tmpdir() inside container + args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`); + + // mount gcloud config directory if it exists + const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud'); + if (fs.existsSync(gcloudConfigDir)) { + args.push( + '--volume', + `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`, + ); + } + + // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set + if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { + const adcFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; + if (fs.existsSync(adcFile)) { + args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`); + args.push( + '--env', + `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`, + ); + } + } + + // mount paths listed in SANDBOX_MOUNTS + if (process.env.SANDBOX_MOUNTS) { + for (let mount of process.env.SANDBOX_MOUNTS.split(',')) { + if (mount.trim()) { + // parse mount as from:to:opts + let [from, to, opts] = mount.trim().split(':'); + to = to || from; // default to mount at same path inside container + opts = opts || 'ro'; // default to read-only + mount = `${from}:${to}:${opts}`; + // check that from path is absolute + if (!path.isAbsolute(from)) { + console.error( + `ERROR: path '${from}' listed in SANDBOX_MOUNTS must be absolute`, + ); + process.exit(1); + } + // check that from path exists on host + if (!fs.existsSync(from)) { + console.error( + `ERROR: missing mount path '${from}' listed in SANDBOX_MOUNTS`, + ); + process.exit(1); + } + console.error(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); + args.push('--volume', mount); } } } - for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { - let dirPath = '/dev/null'; // Default to a safe path that won't cause issues + // expose env-specified ports on the sandbox + ports().forEach((p) => args.push('--publish', `${p}:${p}`)); - if (i < includedDirs.length) { - dirPath = includedDirs[i]; - } - - args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); + // if DEBUG is set, expose debugging port + if (process.env.DEBUG) { + const debugPort = process.env.DEBUG_PORT || '9229'; + args.push(`--publish`, `${debugPort}:${debugPort}`); } - args.push( - '-f', - profileFile, - 'sh', - '-c', - [ - `SANDBOX=sandbox-exec`, - `NODE_OPTIONS="${nodeOptions}"`, - ...process.argv.map((arg) => quote([arg])), - ].join(' '), - ); - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME + // copy as both upper-case and lower-case as is required by some utilities + // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; - let proxyProcess: ChildProcess | undefined = undefined; - let sandboxProcess: ChildProcess | undefined = undefined; - const sandboxEnv = { ...process.env }; + if (proxyCommand) { - const proxy = + let proxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || 'http://localhost:8877'; - sandboxEnv['HTTPS_PROXY'] = proxy; - sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl - sandboxEnv['HTTP_PROXY'] = proxy; - sandboxEnv['http_proxy'] = proxy; + proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); + if (proxy) { + args.push('--env', `HTTPS_PROXY=${proxy}`); + args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl + args.push('--env', `HTTP_PROXY=${proxy}`); + args.push('--env', `http_proxy=${proxy}`); + } const noProxy = process.env.NO_PROXY || process.env.no_proxy; if (noProxy) { - sandboxEnv['NO_PROXY'] = noProxy; - sandboxEnv['no_proxy'] = noProxy; + args.push('--env', `NO_PROXY=${noProxy}`); + args.push('--env', `no_proxy=${noProxy}`); } - proxyProcess = spawn(proxyCommand, { + + // if using proxy, switch to internal networking through proxy + if (proxy) { + execSync( + `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`, + ); + args.push('--network', SANDBOX_NETWORK_NAME); + // if proxy command is set, create a separate network w/ host access (i.e. non-internal) + // we will run proxy in its own container connected to both host network and internal network + // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation + if (proxyCommand) { + execSync( + `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`, + ); + } + } + } + + // name container after image, plus numeric suffix to avoid conflicts + const imageName = parseImageName(image); + let index = 0; + const containerNameCheck = execSync( + `${config.command} ps -a --format "{{.Names}}"`, + ) + .toString() + .trim(); + while (containerNameCheck.includes(`${imageName}-${index}`)) { + index++; + } + const containerName = `${imageName}-${index}`; + args.push('--name', containerName, '--hostname', containerName); + + // copy GEMINI_API_KEY(s) + if (process.env.GEMINI_API_KEY) { + args.push('--env', `GEMINI_API_KEY=${process.env.GEMINI_API_KEY}`); + } + if (process.env.GOOGLE_API_KEY) { + args.push('--env', `GOOGLE_API_KEY=${process.env.GOOGLE_API_KEY}`); + } + + // copy GOOGLE_GENAI_USE_VERTEXAI + if (process.env.GOOGLE_GENAI_USE_VERTEXAI) { + args.push( + '--env', + `GOOGLE_GENAI_USE_VERTEXAI=${process.env.GOOGLE_GENAI_USE_VERTEXAI}`, + ); + } + + // copy GOOGLE_GENAI_USE_GCA + if (process.env.GOOGLE_GENAI_USE_GCA) { + args.push( + '--env', + `GOOGLE_GENAI_USE_GCA=${process.env.GOOGLE_GENAI_USE_GCA}`, + ); + } + + // copy GOOGLE_CLOUD_PROJECT + if (process.env.GOOGLE_CLOUD_PROJECT) { + args.push( + '--env', + `GOOGLE_CLOUD_PROJECT=${process.env.GOOGLE_CLOUD_PROJECT}`, + ); + } + + // copy GOOGLE_CLOUD_LOCATION + if (process.env.GOOGLE_CLOUD_LOCATION) { + args.push( + '--env', + `GOOGLE_CLOUD_LOCATION=${process.env.GOOGLE_CLOUD_LOCATION}`, + ); + } + + // copy GEMINI_MODEL + if (process.env.GEMINI_MODEL) { + args.push('--env', `GEMINI_MODEL=${process.env.GEMINI_MODEL}`); + } + + // copy TERM and COLORTERM to try to maintain terminal setup + if (process.env.TERM) { + args.push('--env', `TERM=${process.env.TERM}`); + } + if (process.env.COLORTERM) { + args.push('--env', `COLORTERM=${process.env.COLORTERM}`); + } + + // copy VIRTUAL_ENV if under working directory + // also mount-replace VIRTUAL_ENV directory with /sandbox.venv + // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) + // directory will be empty if not set up, which is still preferable to having host binaries + if ( + process.env.VIRTUAL_ENV?.toLowerCase().startsWith(workdir.toLowerCase()) + ) { + const sandboxVenvPath = path.resolve( + SETTINGS_DIRECTORY_NAME, + 'sandbox.venv', + ); + if (!fs.existsSync(sandboxVenvPath)) { + fs.mkdirSync(sandboxVenvPath, { recursive: true }); + } + args.push( + '--volume', + `${sandboxVenvPath}:${getContainerPath(process.env.VIRTUAL_ENV)}`, + ); + args.push( + '--env', + `VIRTUAL_ENV=${getContainerPath(process.env.VIRTUAL_ENV)}`, + ); + } + + // copy additional environment variables from SANDBOX_ENV + if (process.env.SANDBOX_ENV) { + for (let env of process.env.SANDBOX_ENV.split(',')) { + if ((env = env.trim())) { + if (env.includes('=')) { + console.error(`SANDBOX_ENV: ${env}`); + args.push('--env', env); + } else { + console.error( + 'ERROR: SANDBOX_ENV must be a comma-separated list of key=value pairs', + ); + process.exit(1); + } + } + } + } + + // copy NODE_OPTIONS + const existingNodeOptions = process.env.NODE_OPTIONS || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ].join(' '); + + if (allNodeOptions.length > 0) { + args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); + } + + // set SANDBOX as container name + args.push('--env', `SANDBOX=${containerName}`); + + // for podman only, use empty --authfile to skip unnecessary auth refresh overhead + if (config.command === 'podman') { + const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); + fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); + args.push('--authfile', emptyAuthFilePath); + } + + // Determine if the current user's UID/GID should be passed to the sandbox. + // See shouldUseCurrentUserInSandbox for more details. + let userFlag = ''; + const finalEntrypoint = entrypoint(workdir); + + if (process.env.GEMINI_CLI_INTEGRATION_TEST === 'true') { + args.push('--user', 'root'); + userFlag = '--user root'; + } else if (await shouldUseCurrentUserInSandbox()) { + // For the user-creation logic to work, the container must start as root. + // The entrypoint script then handles dropping privileges to the correct user. + args.push('--user', 'root'); + + const uid = execSync('id -u').toString().trim(); + const gid = execSync('id -g').toString().trim(); + + // Instead of passing --user to the main sandbox container, we let it + // start as root, then create a user with the host's UID/GID, and + // finally switch to that user to run the gemini process. This is + // necessary on Linux to ensure the user exists within the + // container's /etc/passwd file, which is required by os.userInfo(). + const username = 'gemini'; + const homeDir = getContainerPath(os.homedir()); + + const setupUserCommands = [ + // Use -f with groupadd to avoid errors if the group already exists. + `groupadd -f -g ${gid} ${username}`, + // Create user only if it doesn't exist. Use -o for non-unique UID. + `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`, + ].join(' && '); + + const originalCommand = finalEntrypoint[2]; + const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); + + // Use `su -p` to preserve the environment. + const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`; + + // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. + finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`; + + // We still need userFlag for the simpler proxy container, which does not have this issue. + userFlag = `--user ${uid}:${gid}`; + // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. + args.push('--env', `HOME=${os.homedir()}`); + } + + // push container image name + args.push(image); + + // push container entrypoint (including args) + args.push(...finalEntrypoint); + + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + + if (proxyCommand) { + // run proxyCommand in its own container + const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`; + proxyProcess = spawn(proxyContainerCommand, { stdio: ['ignore', 'pipe', 'pipe'], shell: true, detached: true, }); // install handlers to stop proxy on exit/signal const stopProxy = () => { - console.log('stopping proxy ...'); - if (proxyProcess?.pid) { - process.kill(-proxyProcess.pid, 'SIGTERM'); - } + console.log('stopping proxy container ...'); + execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`); }; process.on('exit', stopProxy); process.on('SIGINT', stopProxy); @@ -308,11 +754,11 @@ export async function start_sandbox( // console.info(data.toString()); // }); proxyProcess.stderr?.on('data', (data) => { - console.error(data.toString()); + console.error(data.toString().trim()); }); proxyProcess.on('close', (code, signal) => { console.error( - `ERROR: proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, + `ERROR: proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, ); if (sandboxProcess?.pid) { process.kill(-sandboxProcess.pid, 'SIGTERM'); @@ -323,467 +769,35 @@ export async function start_sandbox( await execAsync( `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, ); + // connect proxy container to sandbox network + // (workaround for older versions of docker that don't support multiple --network args) + await execAsync( + `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, + ); } + // spawn child and let it inherit stdio sandboxProcess = spawn(config.command, args, { stdio: 'inherit', }); - await new Promise((resolve) => sandboxProcess?.on('close', resolve)); - return; - } - console.error(`hopping into sandbox (command: ${config.command}) ...`); + sandboxProcess.on('error', (err) => { + console.error('Sandbox process error:', err); + }); - // determine full path for gemini-cli to distinguish linked vs installed setting - const gcPath = fs.realpathSync(process.argv[1]); - - const projectSandboxDockerfile = path.join( - SETTINGS_DIRECTORY_NAME, - 'sandbox.Dockerfile', - ); - const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); - - const image = config.image; - const workdir = path.resolve(process.cwd()); - const containerWorkdir = getContainerPath(workdir); - - // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo - // - // note this can only be done with binary linked from gemini-cli repo - if (process.env.BUILD_SANDBOX) { - if (!gcPath.includes('gemini-cli/packages/')) { - console.error( - 'ERROR: cannot build sandbox using installed gemini binary; ' + - 'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.', - ); - process.exit(1); - } else { - console.error('building sandbox ...'); - const gcRoot = gcPath.split('/packages/')[0]; - // if project folder has sandbox.Dockerfile under project settings folder, use that - let buildArgs = ''; - const projectSandboxDockerfile = path.join( - SETTINGS_DIRECTORY_NAME, - 'sandbox.Dockerfile', - ); - if (isCustomProjectSandbox) { - console.error(`using ${projectSandboxDockerfile} for sandbox`); - buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`; - } - execSync( - `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`, - { - stdio: 'inherit', - env: { - ...process.env, - GEMINI_SANDBOX: config.command, // in case sandbox is enabled via flags (see config.ts under cli package) - }, - }, - ); - } - } - - // stop if image is missing - if (!(await ensureSandboxImageIsPresent(config.command, image))) { - const remedy = - image === LOCAL_DEV_SANDBOX_IMAGE_NAME - ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' - : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; - console.error( - `ERROR: Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, - ); - process.exit(1); - } - - // use interactive mode and auto-remove container on exit - // run init binary inside container to forward signals & reap zombies - const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; - - // add custom flags from SANDBOX_FLAGS - if (process.env.SANDBOX_FLAGS) { - const flags = parse(process.env.SANDBOX_FLAGS, process.env).filter( - (f): f is string => typeof f === 'string', - ); - args.push(...flags); - } - - // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container - if (process.stdin.isTTY) { - args.push('-t'); - } - - // mount current directory as working directory in sandbox (set via --workdir) - args.push('--volume', `${workdir}:${containerWorkdir}`); - - // mount user settings directory inside container, after creating if missing - // note user/home changes inside sandbox and we mount at BOTH paths for consistency - const userSettingsDirOnHost = USER_SETTINGS_DIR; - const userSettingsDirInSandbox = getContainerPath( - `/home/node/${SETTINGS_DIRECTORY_NAME}`, - ); - if (!fs.existsSync(userSettingsDirOnHost)) { - fs.mkdirSync(userSettingsDirOnHost); - } - args.push('--volume', `${userSettingsDirOnHost}:${userSettingsDirInSandbox}`); - if (userSettingsDirInSandbox !== userSettingsDirOnHost) { - args.push( - '--volume', - `${userSettingsDirOnHost}:${getContainerPath(userSettingsDirOnHost)}`, - ); - } - - // mount os.tmpdir() as os.tmpdir() inside container - args.push('--volume', `${os.tmpdir()}:${getContainerPath(os.tmpdir())}`); - - // mount gcloud config directory if it exists - const gcloudConfigDir = path.join(os.homedir(), '.config', 'gcloud'); - if (fs.existsSync(gcloudConfigDir)) { - args.push( - '--volume', - `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`, - ); - } - - // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set - if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { - const adcFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; - if (fs.existsSync(adcFile)) { - args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`); - args.push( - '--env', - `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`, - ); - } - } - - // mount paths listed in SANDBOX_MOUNTS - if (process.env.SANDBOX_MOUNTS) { - for (let mount of process.env.SANDBOX_MOUNTS.split(',')) { - if (mount.trim()) { - // parse mount as from:to:opts - let [from, to, opts] = mount.trim().split(':'); - to = to || from; // default to mount at same path inside container - opts = opts || 'ro'; // default to read-only - mount = `${from}:${to}:${opts}`; - // check that from path is absolute - if (!path.isAbsolute(from)) { - console.error( - `ERROR: path '${from}' listed in SANDBOX_MOUNTS must be absolute`, + await new Promise((resolve) => { + sandboxProcess?.on('close', (code, signal) => { + if (code !== 0) { + console.log( + `Sandbox process exited with code: ${code}, signal: ${signal}`, ); - process.exit(1); } - // check that from path exists on host - if (!fs.existsSync(from)) { - console.error( - `ERROR: missing mount path '${from}' listed in SANDBOX_MOUNTS`, - ); - process.exit(1); - } - console.error(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); - args.push('--volume', mount); - } - } - } - - // expose env-specified ports on the sandbox - ports().forEach((p) => args.push('--publish', `${p}:${p}`)); - - // if DEBUG is set, expose debugging port - if (process.env.DEBUG) { - const debugPort = process.env.DEBUG_PORT || '9229'; - args.push(`--publish`, `${debugPort}:${debugPort}`); - } - - // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME - // copy as both upper-case and lower-case as is required by some utilities - // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set - const proxyCommand = process.env.GEMINI_SANDBOX_PROXY_COMMAND; - - if (proxyCommand) { - let proxy = - process.env.HTTPS_PROXY || - process.env.https_proxy || - process.env.HTTP_PROXY || - process.env.http_proxy || - 'http://localhost:8877'; - proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); - if (proxy) { - args.push('--env', `HTTPS_PROXY=${proxy}`); - args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl - args.push('--env', `HTTP_PROXY=${proxy}`); - args.push('--env', `http_proxy=${proxy}`); - } - const noProxy = process.env.NO_PROXY || process.env.no_proxy; - if (noProxy) { - args.push('--env', `NO_PROXY=${noProxy}`); - args.push('--env', `no_proxy=${noProxy}`); - } - - // if using proxy, switch to internal networking through proxy - if (proxy) { - execSync( - `${config.command} network inspect ${SANDBOX_NETWORK_NAME} || ${config.command} network create --internal ${SANDBOX_NETWORK_NAME}`, - ); - args.push('--network', SANDBOX_NETWORK_NAME); - // if proxy command is set, create a separate network w/ host access (i.e. non-internal) - // we will run proxy in its own container connected to both host network and internal network - // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation - if (proxyCommand) { - execSync( - `${config.command} network inspect ${SANDBOX_PROXY_NAME} || ${config.command} network create ${SANDBOX_PROXY_NAME}`, - ); - } - } - } - - // name container after image, plus numeric suffix to avoid conflicts - const imageName = parseImageName(image); - let index = 0; - const containerNameCheck = execSync( - `${config.command} ps -a --format "{{.Names}}"`, - ) - .toString() - .trim(); - while (containerNameCheck.includes(`${imageName}-${index}`)) { - index++; - } - const containerName = `${imageName}-${index}`; - args.push('--name', containerName, '--hostname', containerName); - - // copy GEMINI_API_KEY(s) - if (process.env.GEMINI_API_KEY) { - args.push('--env', `GEMINI_API_KEY=${process.env.GEMINI_API_KEY}`); - } - if (process.env.GOOGLE_API_KEY) { - args.push('--env', `GOOGLE_API_KEY=${process.env.GOOGLE_API_KEY}`); - } - - // copy GOOGLE_GENAI_USE_VERTEXAI - if (process.env.GOOGLE_GENAI_USE_VERTEXAI) { - args.push( - '--env', - `GOOGLE_GENAI_USE_VERTEXAI=${process.env.GOOGLE_GENAI_USE_VERTEXAI}`, - ); - } - - // copy GOOGLE_GENAI_USE_GCA - if (process.env.GOOGLE_GENAI_USE_GCA) { - args.push( - '--env', - `GOOGLE_GENAI_USE_GCA=${process.env.GOOGLE_GENAI_USE_GCA}`, - ); - } - - // copy GOOGLE_CLOUD_PROJECT - if (process.env.GOOGLE_CLOUD_PROJECT) { - args.push( - '--env', - `GOOGLE_CLOUD_PROJECT=${process.env.GOOGLE_CLOUD_PROJECT}`, - ); - } - - // copy GOOGLE_CLOUD_LOCATION - if (process.env.GOOGLE_CLOUD_LOCATION) { - args.push( - '--env', - `GOOGLE_CLOUD_LOCATION=${process.env.GOOGLE_CLOUD_LOCATION}`, - ); - } - - // copy GEMINI_MODEL - if (process.env.GEMINI_MODEL) { - args.push('--env', `GEMINI_MODEL=${process.env.GEMINI_MODEL}`); - } - - // copy TERM and COLORTERM to try to maintain terminal setup - if (process.env.TERM) { - args.push('--env', `TERM=${process.env.TERM}`); - } - if (process.env.COLORTERM) { - args.push('--env', `COLORTERM=${process.env.COLORTERM}`); - } - - // copy VIRTUAL_ENV if under working directory - // also mount-replace VIRTUAL_ENV directory with /sandbox.venv - // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) - // directory will be empty if not set up, which is still preferable to having host binaries - if ( - process.env.VIRTUAL_ENV?.toLowerCase().startsWith(workdir.toLowerCase()) - ) { - const sandboxVenvPath = path.resolve( - SETTINGS_DIRECTORY_NAME, - 'sandbox.venv', - ); - if (!fs.existsSync(sandboxVenvPath)) { - fs.mkdirSync(sandboxVenvPath, { recursive: true }); - } - args.push( - '--volume', - `${sandboxVenvPath}:${getContainerPath(process.env.VIRTUAL_ENV)}`, - ); - args.push( - '--env', - `VIRTUAL_ENV=${getContainerPath(process.env.VIRTUAL_ENV)}`, - ); - } - - // copy additional environment variables from SANDBOX_ENV - if (process.env.SANDBOX_ENV) { - for (let env of process.env.SANDBOX_ENV.split(',')) { - if ((env = env.trim())) { - if (env.includes('=')) { - console.error(`SANDBOX_ENV: ${env}`); - args.push('--env', env); - } else { - console.error( - 'ERROR: SANDBOX_ENV must be a comma-separated list of key=value pairs', - ); - process.exit(1); - } - } - } - } - - // copy NODE_OPTIONS - const existingNodeOptions = process.env.NODE_OPTIONS || ''; - const allNodeOptions = [ - ...(existingNodeOptions ? [existingNodeOptions] : []), - ...nodeArgs, - ].join(' '); - - if (allNodeOptions.length > 0) { - args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); - } - - // set SANDBOX as container name - args.push('--env', `SANDBOX=${containerName}`); - - // for podman only, use empty --authfile to skip unnecessary auth refresh overhead - if (config.command === 'podman') { - const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); - fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); - args.push('--authfile', emptyAuthFilePath); - } - - // Determine if the current user's UID/GID should be passed to the sandbox. - // See shouldUseCurrentUserInSandbox for more details. - let userFlag = ''; - const finalEntrypoint = entrypoint(workdir); - - if (process.env.GEMINI_CLI_INTEGRATION_TEST === 'true') { - args.push('--user', 'root'); - userFlag = '--user root'; - } else if (await shouldUseCurrentUserInSandbox()) { - // For the user-creation logic to work, the container must start as root. - // The entrypoint script then handles dropping privileges to the correct user. - args.push('--user', 'root'); - - const uid = execSync('id -u').toString().trim(); - const gid = execSync('id -g').toString().trim(); - - // Instead of passing --user to the main sandbox container, we let it - // start as root, then create a user with the host's UID/GID, and - // finally switch to that user to run the gemini process. This is - // necessary on Linux to ensure the user exists within the - // container's /etc/passwd file, which is required by os.userInfo(). - const username = 'gemini'; - const homeDir = getContainerPath(os.homedir()); - - const setupUserCommands = [ - // Use -f with groupadd to avoid errors if the group already exists. - `groupadd -f -g ${gid} ${username}`, - // Create user only if it doesn't exist. Use -o for non-unique UID. - `id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`, - ].join(' && '); - - const originalCommand = finalEntrypoint[2]; - const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); - - // Use `su -p` to preserve the environment. - const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`; - - // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. - finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`; - - // We still need userFlag for the simpler proxy container, which does not have this issue. - userFlag = `--user ${uid}:${gid}`; - // When forcing a UID in the sandbox, $HOME can be reset to '/', so we copy $HOME as well. - args.push('--env', `HOME=${os.homedir()}`); - } - - // push container image name - args.push(image); - - // push container entrypoint (including args) - args.push(...finalEntrypoint); - - // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set - let proxyProcess: ChildProcess | undefined = undefined; - let sandboxProcess: ChildProcess | undefined = undefined; - - if (proxyCommand) { - // run proxyCommand in its own container - const proxyContainerCommand = `${config.command} run --rm --init ${userFlag} --name ${SANDBOX_PROXY_NAME} --network ${SANDBOX_PROXY_NAME} -p 8877:8877 -v ${process.cwd()}:${workdir} --workdir ${workdir} ${image} ${proxyCommand}`; - proxyProcess = spawn(proxyContainerCommand, { - stdio: ['ignore', 'pipe', 'pipe'], - shell: true, - detached: true, + resolve(); + }); }); - // install handlers to stop proxy on exit/signal - const stopProxy = () => { - console.log('stopping proxy container ...'); - execSync(`${config.command} rm -f ${SANDBOX_PROXY_NAME}`); - }; - process.on('exit', stopProxy); - process.on('SIGINT', stopProxy); - process.on('SIGTERM', stopProxy); - - // commented out as it disrupts ink rendering - // proxyProcess.stdout?.on('data', (data) => { - // console.info(data.toString()); - // }); - proxyProcess.stderr?.on('data', (data) => { - console.error(data.toString().trim()); - }); - proxyProcess.on('close', (code, signal) => { - console.error( - `ERROR: proxy container command '${proxyContainerCommand}' exited with code ${code}, signal ${signal}`, - ); - if (sandboxProcess?.pid) { - process.kill(-sandboxProcess.pid, 'SIGTERM'); - } - process.exit(1); - }); - console.log('waiting for proxy to start ...'); - await execAsync( - `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, - ); - // connect proxy container to sandbox network - // (workaround for older versions of docker that don't support multiple --network args) - await execAsync( - `${config.command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, - ); + } finally { + patcher.cleanup(); } - - // spawn child and let it inherit stdio - sandboxProcess = spawn(config.command, args, { - stdio: 'inherit', - }); - - sandboxProcess.on('error', (err) => { - console.error('Sandbox process error:', err); - }); - - await new Promise((resolve) => { - sandboxProcess?.on('close', (code, signal) => { - if (code !== 0) { - console.log( - `Sandbox process exited with code: ${code}, signal: ${signal}`, - ); - } - resolve(); - }); - }); } // Helper functions to ensure sandbox image is present From 36750ca49b1b2fa43a3d7904416b876203a1850f Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Wed, 6 Aug 2025 20:34:38 -0400 Subject: [PATCH 032/107] feat(agent): Introduce Foundational Subagent Architecture (#1805) Co-authored-by: Colt McAnlis --- packages/core/src/core/client.ts | 110 +-- packages/core/src/core/geminiChat.ts | 3 + packages/core/src/core/subagent.test.ts | 814 ++++++++++++++++++ packages/core/src/core/subagent.ts | 681 +++++++++++++++ packages/core/src/tools/tool-registry.ts | 16 + .../core/src/utils/environmentContext.test.ts | 205 +++++ packages/core/src/utils/environmentContext.ts | 109 +++ 7 files changed, 1834 insertions(+), 104 deletions(-) create mode 100644 packages/core/src/core/subagent.test.ts create mode 100644 packages/core/src/core/subagent.ts create mode 100644 packages/core/src/utils/environmentContext.test.ts create mode 100644 packages/core/src/utils/environmentContext.ts diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index f8b9a7de..a16a72cc 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -7,14 +7,16 @@ import { EmbedContentParameters, GenerateContentConfig, - Part, SchemaUnion, PartListUnion, Content, Tool, GenerateContentResponse, } from '@google/genai'; -import { getFolderStructure } from '../utils/getFolderStructure.js'; +import { + getDirectoryContextString, + getEnvironmentContext, +} from '../utils/environmentContext.js'; import { Turn, ServerGeminiStreamEvent, @@ -182,112 +184,12 @@ export class GeminiClient { this.getChat().addHistory({ role: 'user', - parts: [{ text: await this.getDirectoryContext() }], + parts: [{ text: await getDirectoryContextString(this.config) }], }); } - private async getDirectoryContext(): Promise { - const workspaceContext = this.config.getWorkspaceContext(); - const workspaceDirectories = workspaceContext.getDirectories(); - - const folderStructures = await Promise.all( - workspaceDirectories.map((dir) => - getFolderStructure(dir, { - fileService: this.config.getFileService(), - }), - ), - ); - - const folderStructure = folderStructures.join('\n'); - const dirList = workspaceDirectories.map((dir) => ` - ${dir}`).join('\n'); - const workingDirPreamble = `I'm currently working in the following directories:\n${dirList}\n Folder structures are as follows:\n${folderStructure}`; - return workingDirPreamble; - } - - private async getEnvironment(): Promise { - const today = new Date().toLocaleDateString(undefined, { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }); - const platform = process.platform; - - const workspaceContext = this.config.getWorkspaceContext(); - const workspaceDirectories = workspaceContext.getDirectories(); - - const folderStructures = await Promise.all( - workspaceDirectories.map((dir) => - getFolderStructure(dir, { - fileService: this.config.getFileService(), - }), - ), - ); - - const folderStructure = folderStructures.join('\n'); - - let workingDirPreamble: string; - if (workspaceDirectories.length === 1) { - workingDirPreamble = `I'm currently working in the directory: ${workspaceDirectories[0]}`; - } else { - const dirList = workspaceDirectories - .map((dir) => ` - ${dir}`) - .join('\n'); - workingDirPreamble = `I'm currently working in the following directories:\n${dirList}`; - } - - const context = ` - This is the Gemini CLI. We are setting up the context for our chat. - Today's date is ${today}. - My operating system is: ${platform} - ${workingDirPreamble} - Here is the folder structure of the current working directories:\n - ${folderStructure} - `.trim(); - - const initialParts: Part[] = [{ text: context }]; - const toolRegistry = await this.config.getToolRegistry(); - - // Add full file context if the flag is set - if (this.config.getFullContext()) { - try { - const readManyFilesTool = toolRegistry.getTool('read_many_files'); - if (readManyFilesTool) { - const invocation = readManyFilesTool.build({ - paths: ['**/*'], // Read everything recursively - useDefaultExcludes: true, // Use default excludes - }); - - // Read all files in the target directory - const result = await invocation.execute(AbortSignal.timeout(30000)); - if (result.llmContent) { - initialParts.push({ - text: `\n--- Full File Context ---\n${result.llmContent}`, - }); - } else { - console.warn( - 'Full context requested, but read_many_files returned no content.', - ); - } - } else { - console.warn( - 'Full context requested, but read_many_files tool not found.', - ); - } - } catch (error) { - // Not using reportError here as it's a startup/config phase, not a chat/generation phase error. - console.error('Error reading full file context:', error); - initialParts.push({ - text: '\n--- Error reading full file context ---', - }); - } - } - - return initialParts; - } - async startChat(extraHistory?: Content[]): Promise { - const envParts = await this.getEnvironment(); + const envParts = await getEnvironmentContext(this.config); const toolRegistry = await this.config.getToolRegistry(); const toolDeclarations = toolRegistry.getFunctionDeclarations(); const tools: Tool[] = [{ functionDeclarations: toolDeclarations }]; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5f5b22e8..cff23d2d 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -242,6 +242,9 @@ export class GeminiChat { return null; } + setSystemInstruction(sysInstr: string) { + this.generationConfig.systemInstruction = sysInstr; + } /** * Sends a message to the model and returns the response. * diff --git a/packages/core/src/core/subagent.test.ts b/packages/core/src/core/subagent.test.ts new file mode 100644 index 00000000..889feb45 --- /dev/null +++ b/packages/core/src/core/subagent.test.ts @@ -0,0 +1,814 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, Mock, afterEach } from 'vitest'; +import { + ContextState, + SubAgentScope, + SubagentTerminateMode, + PromptConfig, + ModelConfig, + RunConfig, + OutputConfig, + ToolConfig, +} from './subagent.js'; +import { Config, ConfigParameters } from '../config/config.js'; +import { GeminiChat } from './geminiChat.js'; +import { createContentGenerator } from './contentGenerator.js'; +import { getEnvironmentContext } from '../utils/environmentContext.js'; +import { executeToolCall } from './nonInteractiveToolExecutor.js'; +import { ToolRegistry } from '../tools/tool-registry.js'; +import { DEFAULT_GEMINI_MODEL } from '../config/models.js'; +import { + Content, + FunctionCall, + FunctionDeclaration, + GenerateContentConfig, + Type, +} from '@google/genai'; +import { ToolErrorType } from '../tools/tool-error.js'; + +vi.mock('./geminiChat.js'); +vi.mock('./contentGenerator.js'); +vi.mock('../utils/environmentContext.js'); +vi.mock('./nonInteractiveToolExecutor.js'); +vi.mock('../ide/ide-client.js'); + +async function createMockConfig( + toolRegistryMocks = {}, +): Promise<{ config: Config; toolRegistry: ToolRegistry }> { + const configParams: ConfigParameters = { + sessionId: 'test-session', + model: DEFAULT_GEMINI_MODEL, + targetDir: '.', + debugMode: false, + cwd: process.cwd(), + }; + const config = new Config(configParams); + await config.initialize(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await config.refreshAuth('test-auth' as any); + + // Mock ToolRegistry + const mockToolRegistry = { + getTool: vi.fn(), + getFunctionDeclarationsFiltered: vi.fn().mockReturnValue([]), + ...toolRegistryMocks, + } as unknown as ToolRegistry; + + vi.spyOn(config, 'getToolRegistry').mockResolvedValue(mockToolRegistry); + return { config, toolRegistry: mockToolRegistry }; +} + +// Helper to simulate LLM responses (sequence of tool calls over multiple turns) +const createMockStream = ( + functionCallsList: Array, +) => { + let index = 0; + return vi.fn().mockImplementation(() => { + const response = functionCallsList[index] || 'stop'; + index++; + return (async function* () { + if (response === 'stop') { + // When stopping, the model might return text, but the subagent logic primarily cares about the absence of functionCalls. + yield { text: 'Done.' }; + } else if (response.length > 0) { + yield { functionCalls: response }; + } else { + yield { text: 'Done.' }; // Handle empty array also as stop + } + })(); + }); +}; + +describe('subagent.ts', () => { + describe('ContextState', () => { + it('should set and get values correctly', () => { + const context = new ContextState(); + context.set('key1', 'value1'); + context.set('key2', 123); + expect(context.get('key1')).toBe('value1'); + expect(context.get('key2')).toBe(123); + expect(context.get_keys()).toEqual(['key1', 'key2']); + }); + + it('should return undefined for missing keys', () => { + const context = new ContextState(); + expect(context.get('missing')).toBeUndefined(); + }); + }); + + describe('SubAgentScope', () => { + let mockSendMessageStream: Mock; + + const defaultModelConfig: ModelConfig = { + model: 'gemini-1.5-flash-latest', + temp: 0.5, // Specific temp to test override + top_p: 1, + }; + + const defaultRunConfig: RunConfig = { + max_time_minutes: 5, + max_turns: 10, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + vi.mocked(getEnvironmentContext).mockResolvedValue([ + { text: 'Env Context' }, + ]); + vi.mocked(createContentGenerator).mockResolvedValue({ + getGenerativeModel: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + mockSendMessageStream = vi.fn(); + // We mock the implementation of the constructor. + vi.mocked(GeminiChat).mockImplementation( + () => + ({ + sendMessageStream: mockSendMessageStream, + }) as unknown as GeminiChat, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Helper to safely access generationConfig from mock calls + const getGenerationConfigFromMock = ( + callIndex = 0, + ): GenerateContentConfig & { systemInstruction?: string | Content } => { + const callArgs = vi.mocked(GeminiChat).mock.calls[callIndex]; + const generationConfig = callArgs?.[2]; + // Ensure it's defined before proceeding + expect(generationConfig).toBeDefined(); + if (!generationConfig) throw new Error('generationConfig is undefined'); + return generationConfig as GenerateContentConfig & { + systemInstruction?: string | Content; + }; + }; + + describe('create (Tool Validation)', () => { + const promptConfig: PromptConfig = { systemPrompt: 'Test prompt' }; + + it('should create a SubAgentScope successfully with minimal config', async () => { + const { config } = await createMockConfig(); + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + ); + expect(scope).toBeInstanceOf(SubAgentScope); + }); + + it('should throw an error if a tool requires confirmation', async () => { + const mockTool = { + schema: { parameters: { type: Type.OBJECT, properties: {} } }, + build: vi.fn().mockReturnValue({ + shouldConfirmExecute: vi.fn().mockResolvedValue({ + type: 'exec', + title: 'Confirm', + command: 'rm -rf /', + }), + }), + }; + + const { config } = await createMockConfig({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getTool: vi.fn().mockReturnValue(mockTool as any), + }); + + const toolConfig: ToolConfig = { tools: ['risky_tool'] }; + + await expect( + SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + ), + ).rejects.toThrow( + 'Tool "risky_tool" requires user confirmation and cannot be used in a non-interactive subagent.', + ); + }); + + it('should succeed if tools do not require confirmation', async () => { + const mockTool = { + schema: { parameters: { type: Type.OBJECT, properties: {} } }, + build: vi.fn().mockReturnValue({ + shouldConfirmExecute: vi.fn().mockResolvedValue(null), + }), + }; + const { config } = await createMockConfig({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getTool: vi.fn().mockReturnValue(mockTool as any), + }); + + const toolConfig: ToolConfig = { tools: ['safe_tool'] }; + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + ); + expect(scope).toBeInstanceOf(SubAgentScope); + }); + + it('should skip interactivity check and warn for tools with required parameters', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const mockToolWithParams = { + schema: { + parameters: { + type: Type.OBJECT, + properties: { + path: { type: Type.STRING }, + }, + required: ['path'], + }, + }, + // build should not be called, but we mock it to be safe + build: vi.fn(), + }; + + const { config } = await createMockConfig({ + getTool: vi.fn().mockReturnValue(mockToolWithParams), + }); + + const toolConfig: ToolConfig = { tools: ['tool_with_params'] }; + + // The creation should succeed without throwing + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + ); + + expect(scope).toBeInstanceOf(SubAgentScope); + + // Check that the warning was logged + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Cannot check tool "tool_with_params" for interactivity because it requires parameters. Assuming it is safe for non-interactive use.', + ); + + // Ensure build was never called + expect(mockToolWithParams.build).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + }); + + describe('runNonInteractive - Initialization and Prompting', () => { + it('should correctly template the system prompt and initialize GeminiChat', async () => { + const { config } = await createMockConfig(); + + vi.mocked(GeminiChat).mockClear(); + + const promptConfig: PromptConfig = { + systemPrompt: 'Hello ${name}, your task is ${task}.', + }; + const context = new ContextState(); + context.set('name', 'Agent'); + context.set('task', 'Testing'); + + // Model stops immediately + mockSendMessageStream.mockImplementation(createMockStream(['stop'])); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + ); + + await scope.runNonInteractive(context); + + // Check if GeminiChat was initialized correctly by the subagent + expect(GeminiChat).toHaveBeenCalledTimes(1); + const callArgs = vi.mocked(GeminiChat).mock.calls[0]; + + // Check Generation Config + const generationConfig = getGenerationConfigFromMock(); + + // Check temperature override + expect(generationConfig.temperature).toBe(defaultModelConfig.temp); + expect(generationConfig.systemInstruction).toContain( + 'Hello Agent, your task is Testing.', + ); + expect(generationConfig.systemInstruction).toContain( + 'Important Rules:', + ); + + // Check History (should include environment context) + const history = callArgs[3]; + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'Env Context' }] }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the context!' }], + }, + ]); + }); + + it('should include output instructions in the system prompt when outputs are defined', async () => { + const { config } = await createMockConfig(); + vi.mocked(GeminiChat).mockClear(); + + const promptConfig: PromptConfig = { systemPrompt: 'Do the task.' }; + const outputConfig: OutputConfig = { + outputs: { + result1: 'The first result', + }, + }; + const context = new ContextState(); + + // Model stops immediately + mockSendMessageStream.mockImplementation(createMockStream(['stop'])); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + undefined, // ToolConfig + outputConfig, + ); + + await scope.runNonInteractive(context); + + const generationConfig = getGenerationConfigFromMock(); + const systemInstruction = generationConfig.systemInstruction as string; + + expect(systemInstruction).toContain('Do the task.'); + expect(systemInstruction).toContain( + 'you MUST emit the required output variables', + ); + expect(systemInstruction).toContain( + "Use 'self.emitvalue' to emit the 'result1' key", + ); + }); + + it('should use initialMessages instead of systemPrompt if provided', async () => { + const { config } = await createMockConfig(); + vi.mocked(GeminiChat).mockClear(); + + const initialMessages: Content[] = [ + { role: 'user', parts: [{ text: 'Hi' }] }, + ]; + const promptConfig: PromptConfig = { initialMessages }; + const context = new ContextState(); + + // Model stops immediately + mockSendMessageStream.mockImplementation(createMockStream(['stop'])); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + ); + + await scope.runNonInteractive(context); + + const callArgs = vi.mocked(GeminiChat).mock.calls[0]; + const generationConfig = getGenerationConfigFromMock(); + const history = callArgs[3]; + + expect(generationConfig.systemInstruction).toBeUndefined(); + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'Env Context' }] }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the context!' }], + }, + ...initialMessages, + ]); + }); + + it('should throw an error if template variables are missing', async () => { + const { config } = await createMockConfig(); + const promptConfig: PromptConfig = { + systemPrompt: 'Hello ${name}, you are missing ${missing}.', + }; + const context = new ContextState(); + context.set('name', 'Agent'); + // 'missing' is not set + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + ); + + // The error from templating causes the runNonInteractive to reject and the terminate_reason to be ERROR. + await expect(scope.runNonInteractive(context)).rejects.toThrow( + 'Missing context values for the following keys: missing', + ); + expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR); + }); + + it('should validate that systemPrompt and initialMessages are mutually exclusive', async () => { + const { config } = await createMockConfig(); + const promptConfig: PromptConfig = { + systemPrompt: 'System', + initialMessages: [{ role: 'user', parts: [{ text: 'Hi' }] }], + }; + const context = new ContextState(); + + const agent = await SubAgentScope.create( + 'TestAgent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + ); + + await expect(agent.runNonInteractive(context)).rejects.toThrow( + 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', + ); + expect(agent.output.terminate_reason).toBe(SubagentTerminateMode.ERROR); + }); + }); + + describe('runNonInteractive - Execution and Tool Use', () => { + const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' }; + + it('should terminate with GOAL if no outputs are expected and model stops', async () => { + const { config } = await createMockConfig(); + // Model stops immediately + mockSendMessageStream.mockImplementation(createMockStream(['stop'])); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + // No ToolConfig, No OutputConfig + ); + + await scope.runNonInteractive(new ContextState()); + + expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); + expect(scope.output.emitted_vars).toEqual({}); + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + // Check the initial message + expect(mockSendMessageStream.mock.calls[0][0].message).toEqual([ + { text: 'Get Started!' }, + ]); + }); + + it('should handle self.emitvalue and terminate with GOAL when outputs are met', async () => { + const { config } = await createMockConfig(); + const outputConfig: OutputConfig = { + outputs: { result: 'The final result' }, + }; + + // Turn 1: Model responds with emitvalue call + // Turn 2: Model stops after receiving the tool response + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + name: 'self.emitvalue', + args: { + emit_variable_name: 'result', + emit_variable_value: 'Success!', + }, + }, + ], + 'stop', + ]), + ); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + undefined, + outputConfig, + ); + + await scope.runNonInteractive(new ContextState()); + + expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); + expect(scope.output.emitted_vars).toEqual({ result: 'Success!' }); + expect(mockSendMessageStream).toHaveBeenCalledTimes(2); + + // Check the tool response sent back in the second call + const secondCallArgs = mockSendMessageStream.mock.calls[1][0]; + expect(secondCallArgs.message).toEqual([ + { text: 'Emitted variable result successfully' }, + ]); + }); + + it('should execute external tools and provide the response to the model', async () => { + const listFilesToolDef: FunctionDeclaration = { + name: 'list_files', + description: 'Lists files', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + const { config, toolRegistry } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([listFilesToolDef]), + }); + const toolConfig: ToolConfig = { tools: ['list_files'] }; + + // Turn 1: Model calls the external tool + // Turn 2: Model stops + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_1', + name: 'list_files', + args: { path: '.' }, + }, + ], + 'stop', + ]), + ); + + // Mock the tool execution result + vi.mocked(executeToolCall).mockResolvedValue({ + callId: 'call_1', + responseParts: 'file1.txt\nfile2.ts', + resultDisplay: 'Listed 2 files', + error: undefined, + errorType: undefined, // Or ToolErrorType.NONE if available and appropriate + }); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + ); + + await scope.runNonInteractive(new ContextState()); + + // Check tool execution + expect(executeToolCall).toHaveBeenCalledWith( + config, + expect.objectContaining({ name: 'list_files', args: { path: '.' } }), + toolRegistry, + expect.any(AbortSignal), + ); + + // Check the response sent back to the model + const secondCallArgs = mockSendMessageStream.mock.calls[1][0]; + expect(secondCallArgs.message).toEqual([ + { text: 'file1.txt\nfile2.ts' }, + ]); + + expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); + }); + + it('should provide specific tool error responses to the model', async () => { + const { config } = await createMockConfig(); + const toolConfig: ToolConfig = { tools: ['failing_tool'] }; + + // Turn 1: Model calls the failing tool + // Turn 2: Model stops after receiving the error response + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + id: 'call_fail', + name: 'failing_tool', + args: {}, + }, + ], + 'stop', + ]), + ); + + // Mock the tool execution failure. + vi.mocked(executeToolCall).mockResolvedValue({ + callId: 'call_fail', + responseParts: 'ERROR: Tool failed catastrophically', // This should be sent to the model + resultDisplay: 'Tool failed catastrophically', + error: new Error('Failure'), + errorType: ToolErrorType.INVALID_TOOL_PARAMS, + }); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + ); + + await scope.runNonInteractive(new ContextState()); + + // The agent should send the specific error message from responseParts. + const secondCallArgs = mockSendMessageStream.mock.calls[1][0]; + + expect(secondCallArgs.message).toEqual([ + { + text: 'ERROR: Tool failed catastrophically', + }, + ]); + }); + + it('should nudge the model if it stops before emitting all required variables', async () => { + const { config } = await createMockConfig(); + const outputConfig: OutputConfig = { + outputs: { required_var: 'Must be present' }, + }; + + // Turn 1: Model stops prematurely + // Turn 2: Model responds to the nudge and emits the variable + // Turn 3: Model stops + mockSendMessageStream.mockImplementation( + createMockStream([ + 'stop', + [ + { + name: 'self.emitvalue', + args: { + emit_variable_name: 'required_var', + emit_variable_value: 'Here it is', + }, + }, + ], + 'stop', + ]), + ); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + undefined, + outputConfig, + ); + + await scope.runNonInteractive(new ContextState()); + + // Check the nudge message sent in Turn 2 + const secondCallArgs = mockSendMessageStream.mock.calls[1][0]; + + // We check that the message contains the required variable name and the nudge phrasing. + expect(secondCallArgs.message[0].text).toContain('required_var'); + expect(secondCallArgs.message[0].text).toContain( + 'You have stopped calling tools', + ); + + expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.GOAL); + expect(scope.output.emitted_vars).toEqual({ + required_var: 'Here it is', + }); + expect(mockSendMessageStream).toHaveBeenCalledTimes(3); + }); + }); + + describe('runNonInteractive - Termination and Recovery', () => { + const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' }; + + it('should terminate with MAX_TURNS if the limit is reached', async () => { + const { config } = await createMockConfig(); + const runConfig: RunConfig = { ...defaultRunConfig, max_turns: 2 }; + + // Model keeps looping by calling emitvalue repeatedly + mockSendMessageStream.mockImplementation( + createMockStream([ + [ + { + name: 'self.emitvalue', + args: { emit_variable_name: 'loop', emit_variable_value: 'v1' }, + }, + ], + [ + { + name: 'self.emitvalue', + args: { emit_variable_name: 'loop', emit_variable_value: 'v2' }, + }, + ], + // This turn should not happen + [ + { + name: 'self.emitvalue', + args: { emit_variable_name: 'loop', emit_variable_value: 'v3' }, + }, + ], + ]), + ); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + runConfig, + ); + + await scope.runNonInteractive(new ContextState()); + + expect(mockSendMessageStream).toHaveBeenCalledTimes(2); + expect(scope.output.terminate_reason).toBe( + SubagentTerminateMode.MAX_TURNS, + ); + }); + + it('should terminate with TIMEOUT if the time limit is reached during an LLM call', async () => { + // Use fake timers to reliably test timeouts + vi.useFakeTimers(); + + const { config } = await createMockConfig(); + const runConfig: RunConfig = { max_time_minutes: 5, max_turns: 100 }; + + // We need to control the resolution of the sendMessageStream promise to advance the timer during execution. + let resolveStream: ( + value: AsyncGenerator, + ) => void; + const streamPromise = new Promise< + AsyncGenerator + >((resolve) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolveStream = resolve as any; + }); + + // The LLM call will hang until we resolve the promise. + mockSendMessageStream.mockReturnValue(streamPromise); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + runConfig, + ); + + const runPromise = scope.runNonInteractive(new ContextState()); + + // Advance time beyond the limit (6 minutes) while the agent is awaiting the LLM response. + await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + + // Now resolve the stream. The model returns 'stop'. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolveStream!(createMockStream(['stop'])() as any); + + await runPromise; + + expect(scope.output.terminate_reason).toBe( + SubagentTerminateMode.TIMEOUT, + ); + expect(mockSendMessageStream).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it('should terminate with ERROR if the model call throws', async () => { + const { config } = await createMockConfig(); + mockSendMessageStream.mockRejectedValue(new Error('API Failure')); + + const scope = await SubAgentScope.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + ); + + await expect( + scope.runNonInteractive(new ContextState()), + ).rejects.toThrow('API Failure'); + expect(scope.output.terminate_reason).toBe(SubagentTerminateMode.ERROR); + }); + }); + }); +}); diff --git a/packages/core/src/core/subagent.ts b/packages/core/src/core/subagent.ts new file mode 100644 index 00000000..e11a5209 --- /dev/null +++ b/packages/core/src/core/subagent.ts @@ -0,0 +1,681 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { reportError } from '../utils/errorReporting.js'; +import { ToolRegistry } from '../tools/tool-registry.js'; +import { Config } from '../config/config.js'; +import { ToolCallRequestInfo } from './turn.js'; +import { executeToolCall } from './nonInteractiveToolExecutor.js'; +import { createContentGenerator } from './contentGenerator.js'; +import { getEnvironmentContext } from '../utils/environmentContext.js'; +import { + Content, + Part, + FunctionCall, + GenerateContentConfig, + FunctionDeclaration, + Type, +} from '@google/genai'; +import { GeminiChat } from './geminiChat.js'; + +/** + * @fileoverview Defines the configuration interfaces for a subagent. + * + * These interfaces specify the structure for defining the subagent's prompt, + * the model parameters, and the execution settings. + */ + +/** + * Describes the possible termination modes for a subagent. + * This enum provides a clear indication of why a subagent's execution might have ended. + */ +export enum SubagentTerminateMode { + /** + * Indicates that the subagent's execution terminated due to an unrecoverable error. + */ + ERROR = 'ERROR', + /** + * Indicates that the subagent's execution terminated because it exceeded the maximum allowed working time. + */ + TIMEOUT = 'TIMEOUT', + /** + * Indicates that the subagent's execution successfully completed all its defined goals. + */ + GOAL = 'GOAL', + /** + * Indicates that the subagent's execution terminated because it exceeded the maximum number of turns. + */ + MAX_TURNS = 'MAX_TURNS', +} + +/** + * Represents the output structure of a subagent's execution. + * This interface defines the data that a subagent will return upon completion, + * including any emitted variables and the reason for its termination. + */ +export interface OutputObject { + /** + * A record of key-value pairs representing variables emitted by the subagent + * during its execution. These variables can be used by the calling agent. + */ + emitted_vars: Record; + /** + * The reason for the subagent's termination, indicating whether it completed + * successfully, timed out, or encountered an error. + */ + terminate_reason: SubagentTerminateMode; +} + +/** + * Configures the initial prompt for the subagent. + */ +export interface PromptConfig { + /** + * A single system prompt string that defines the subagent's persona and instructions. + * Note: You should use either `systemPrompt` or `initialMessages`, but not both. + */ + systemPrompt?: string; + + /** + * An array of user/model content pairs to seed the chat history for few-shot prompting. + * Note: You should use either `systemPrompt` or `initialMessages`, but not both. + */ + initialMessages?: Content[]; +} + +/** + * Configures the tools available to the subagent during its execution. + */ +export interface ToolConfig { + /** + * A list of tool names (from the tool registry) or full function declarations + * that the subagent is permitted to use. + */ + tools: Array; +} + +/** + * Configures the expected outputs for the subagent. + */ +export interface OutputConfig { + /** + * A record describing the variables the subagent is expected to emit. + * The subagent will be prompted to generate these values before terminating. + */ + outputs: Record; +} + +/** + * Configures the generative model parameters for the subagent. + * This interface specifies the model to be used and its associated generation settings, + * such as temperature and top-p values, which influence the creativity and diversity of the model's output. + */ +export interface ModelConfig { + /** + * The name or identifier of the model to be used (e.g., 'gemini-2.5-pro'). + * + * TODO: In the future, this needs to support 'auto' or some other string to support routing use cases. + */ + model: string; + /** + * The temperature for the model's sampling process. + */ + temp: number; + /** + * The top-p value for nucleus sampling. + */ + top_p: number; +} + +/** + * Configures the execution environment and constraints for the subagent. + * This interface defines parameters that control the subagent's runtime behavior, + * such as maximum execution time, to prevent infinite loops or excessive resource consumption. + * + * TODO: Consider adding max_tokens as a form of budgeting. + */ +export interface RunConfig { + /** The maximum execution time for the subagent in minutes. */ + max_time_minutes: number; + /** + * The maximum number of conversational turns (a user message + model response) + * before the execution is terminated. Helps prevent infinite loops. + */ + max_turns?: number; +} + +/** + * Manages the runtime context state for the subagent. + * This class provides a mechanism to store and retrieve key-value pairs + * that represent the dynamic state and variables accessible to the subagent + * during its execution. + */ +export class ContextState { + private state: Record = {}; + + /** + * Retrieves a value from the context state. + * + * @param key - The key of the value to retrieve. + * @returns The value associated with the key, or undefined if the key is not found. + */ + get(key: string): unknown { + return this.state[key]; + } + + /** + * Sets a value in the context state. + * + * @param key - The key to set the value under. + * @param value - The value to set. + */ + set(key: string, value: unknown): void { + this.state[key] = value; + } + + /** + * Retrieves all keys in the context state. + * + * @returns An array of all keys in the context state. + */ + get_keys(): string[] { + return Object.keys(this.state); + } +} + +/** + * Replaces `${...}` placeholders in a template string with values from a context. + * + * This function identifies all placeholders in the format `${key}`, validates that + * each key exists in the provided `ContextState`, and then performs the substitution. + * + * @param template The template string containing placeholders. + * @param context The `ContextState` object providing placeholder values. + * @returns The populated string with all placeholders replaced. + * @throws {Error} if any placeholder key is not found in the context. + */ +function templateString(template: string, context: ContextState): string { + const placeholderRegex = /\$\{(\w+)\}/g; + + // First, find all unique keys required by the template. + const requiredKeys = new Set( + Array.from(template.matchAll(placeholderRegex), (match) => match[1]), + ); + + // Check if all required keys exist in the context. + const contextKeys = new Set(context.get_keys()); + const missingKeys = Array.from(requiredKeys).filter( + (key) => !contextKeys.has(key), + ); + + if (missingKeys.length > 0) { + throw new Error( + `Missing context values for the following keys: ${missingKeys.join( + ', ', + )}`, + ); + } + + // Perform the replacement using a replacer function. + return template.replace(placeholderRegex, (_match, key) => + String(context.get(key)), + ); +} + +/** + * Represents the scope and execution environment for a subagent. + * This class orchestrates the subagent's lifecycle, managing its chat interactions, + * runtime context, and the collection of its outputs. + */ +export class SubAgentScope { + output: OutputObject = { + terminate_reason: SubagentTerminateMode.ERROR, + emitted_vars: {}, + }; + private readonly subagentId: string; + + /** + * Constructs a new SubAgentScope instance. + * @param name - The name for the subagent, used for logging and identification. + * @param runtimeContext - The shared runtime configuration and services. + * @param promptConfig - Configuration for the subagent's prompt and behavior. + * @param modelConfig - Configuration for the generative model parameters. + * @param runConfig - Configuration for the subagent's execution environment. + * @param toolConfig - Optional configuration for tools available to the subagent. + * @param outputConfig - Optional configuration for the subagent's expected outputs. + */ + private constructor( + readonly name: string, + readonly runtimeContext: Config, + private readonly promptConfig: PromptConfig, + private readonly modelConfig: ModelConfig, + private readonly runConfig: RunConfig, + private readonly toolConfig?: ToolConfig, + private readonly outputConfig?: OutputConfig, + ) { + const randomPart = Math.random().toString(36).slice(2, 8); + this.subagentId = `${this.name}-${randomPart}`; + } + + /** + * Creates and validates a new SubAgentScope instance. + * This factory method ensures that all tools provided in the prompt configuration + * are valid for non-interactive use before creating the subagent instance. + * @param {string} name - The name of the subagent. + * @param {Config} runtimeContext - The shared runtime configuration and services. + * @param {PromptConfig} promptConfig - Configuration for the subagent's prompt and behavior. + * @param {ModelConfig} modelConfig - Configuration for the generative model parameters. + * @param {RunConfig} runConfig - Configuration for the subagent's execution environment. + * @param {ToolConfig} [toolConfig] - Optional configuration for tools. + * @param {OutputConfig} [outputConfig] - Optional configuration for expected outputs. + * @returns {Promise} A promise that resolves to a valid SubAgentScope instance. + * @throws {Error} If any tool requires user confirmation. + */ + static async create( + name: string, + runtimeContext: Config, + promptConfig: PromptConfig, + modelConfig: ModelConfig, + runConfig: RunConfig, + toolConfig?: ToolConfig, + outputConfig?: OutputConfig, + ): Promise { + if (toolConfig) { + const toolRegistry: ToolRegistry = await runtimeContext.getToolRegistry(); + const toolsToLoad: string[] = []; + for (const tool of toolConfig.tools) { + if (typeof tool === 'string') { + toolsToLoad.push(tool); + } + } + + for (const toolName of toolsToLoad) { + const tool = toolRegistry.getTool(toolName); + if (tool) { + const requiredParams = tool.schema.parameters?.required ?? []; + if (requiredParams.length > 0) { + // This check is imperfect. A tool might require parameters but still + // be interactive (e.g., `delete_file(path)`). However, we cannot + // build a generic invocation without knowing what dummy parameters + // to provide. Crashing here because `build({})` fails is worse + // than allowing a potential hang later if an interactive tool is + // used. This is a best-effort check. + console.warn( + `Cannot check tool "${toolName}" for interactivity because it requires parameters. Assuming it is safe for non-interactive use.`, + ); + continue; + } + + const invocation = tool.build({}); + const confirmationDetails = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + if (confirmationDetails) { + throw new Error( + `Tool "${toolName}" requires user confirmation and cannot be used in a non-interactive subagent.`, + ); + } + } + } + } + + return new SubAgentScope( + name, + runtimeContext, + promptConfig, + modelConfig, + runConfig, + toolConfig, + outputConfig, + ); + } + + /** + * Runs the subagent in a non-interactive mode. + * This method orchestrates the subagent's execution loop, including prompt templating, + * tool execution, and termination conditions. + * @param {ContextState} context - The current context state containing variables for prompt templating. + * @returns {Promise} A promise that resolves when the subagent has completed its execution. + */ + async runNonInteractive(context: ContextState): Promise { + const chat = await this.createChatObject(context); + + if (!chat) { + this.output.terminate_reason = SubagentTerminateMode.ERROR; + return; + } + + const abortController = new AbortController(); + const toolRegistry: ToolRegistry = + await this.runtimeContext.getToolRegistry(); + + // Prepare the list of tools available to the subagent. + const toolsList: FunctionDeclaration[] = []; + if (this.toolConfig) { + const toolsToLoad: string[] = []; + for (const tool of this.toolConfig.tools) { + if (typeof tool === 'string') { + toolsToLoad.push(tool); + } else { + toolsList.push(tool); + } + } + toolsList.push( + ...toolRegistry.getFunctionDeclarationsFiltered(toolsToLoad), + ); + } + // Add local scope functions if outputs are expected. + if (this.outputConfig && this.outputConfig.outputs) { + toolsList.push(...this.getScopeLocalFuncDefs()); + } + + let currentMessages: Content[] = [ + { role: 'user', parts: [{ text: 'Get Started!' }] }, + ]; + + const startTime = Date.now(); + let turnCounter = 0; + try { + while (true) { + // Check termination conditions. + if ( + this.runConfig.max_turns && + turnCounter >= this.runConfig.max_turns + ) { + this.output.terminate_reason = SubagentTerminateMode.MAX_TURNS; + break; + } + let durationMin = (Date.now() - startTime) / (1000 * 60); + if (durationMin >= this.runConfig.max_time_minutes) { + this.output.terminate_reason = SubagentTerminateMode.TIMEOUT; + break; + } + + const promptId = `${this.runtimeContext.getSessionId()}#${this.subagentId}#${turnCounter++}`; + const messageParams = { + message: currentMessages[0]?.parts || [], + config: { + abortSignal: abortController.signal, + tools: [{ functionDeclarations: toolsList }], + }, + }; + + const responseStream = await chat.sendMessageStream( + messageParams, + promptId, + ); + + const functionCalls: FunctionCall[] = []; + for await (const resp of responseStream) { + if (abortController.signal.aborted) return; + if (resp.functionCalls) functionCalls.push(...resp.functionCalls); + } + + durationMin = (Date.now() - startTime) / (1000 * 60); + if (durationMin >= this.runConfig.max_time_minutes) { + this.output.terminate_reason = SubagentTerminateMode.TIMEOUT; + break; + } + + if (functionCalls.length > 0) { + currentMessages = await this.processFunctionCalls( + functionCalls, + toolRegistry, + abortController, + promptId, + ); + } else { + // Model stopped calling tools. Check if goal is met. + if ( + !this.outputConfig || + Object.keys(this.outputConfig.outputs).length === 0 + ) { + this.output.terminate_reason = SubagentTerminateMode.GOAL; + break; + } + + const remainingVars = Object.keys(this.outputConfig.outputs).filter( + (key) => !(key in this.output.emitted_vars), + ); + + if (remainingVars.length === 0) { + this.output.terminate_reason = SubagentTerminateMode.GOAL; + break; + } + + const nudgeMessage = `You have stopped calling tools but have not emitted the following required variables: ${remainingVars.join( + ', ', + )}. Please use the 'self.emitvalue' tool to emit them now, or continue working if necessary.`; + + console.debug(nudgeMessage); + + currentMessages = [ + { + role: 'user', + parts: [{ text: nudgeMessage }], + }, + ]; + } + } + } catch (error) { + console.error('Error during subagent execution:', error); + this.output.terminate_reason = SubagentTerminateMode.ERROR; + throw error; + } + } + + /** + * Processes a list of function calls, executing each one and collecting their responses. + * This method iterates through the provided function calls, executes them using the + * `executeToolCall` function (or handles `self.emitvalue` internally), and aggregates + * their results. It also manages error reporting for failed tool executions. + * @param {FunctionCall[]} functionCalls - An array of `FunctionCall` objects to process. + * @param {ToolRegistry} toolRegistry - The tool registry to look up and execute tools. + * @param {AbortController} abortController - An `AbortController` to signal cancellation of tool executions. + * @returns {Promise} A promise that resolves to an array of `Content` parts representing the tool responses, + * which are then used to update the chat history. + */ + private async processFunctionCalls( + functionCalls: FunctionCall[], + toolRegistry: ToolRegistry, + abortController: AbortController, + promptId: string, + ): Promise { + const toolResponseParts: Part[] = []; + + for (const functionCall of functionCalls) { + const callId = functionCall.id ?? `${functionCall.name}-${Date.now()}`; + const requestInfo: ToolCallRequestInfo = { + callId, + name: functionCall.name as string, + args: (functionCall.args ?? {}) as Record, + isClientInitiated: true, + prompt_id: promptId, + }; + + let toolResponse; + + // Handle scope-local tools first. + if (functionCall.name === 'self.emitvalue') { + const valName = String(requestInfo.args['emit_variable_name']); + const valVal = String(requestInfo.args['emit_variable_value']); + this.output.emitted_vars[valName] = valVal; + + toolResponse = { + callId, + responseParts: `Emitted variable ${valName} successfully`, + resultDisplay: `Emitted variable ${valName} successfully`, + error: undefined, + }; + } else { + toolResponse = await executeToolCall( + this.runtimeContext, + requestInfo, + toolRegistry, + abortController.signal, + ); + } + + if (toolResponse.error) { + console.error( + `Error executing tool ${functionCall.name}: ${toolResponse.resultDisplay || toolResponse.error.message}`, + ); + } + + if (toolResponse.responseParts) { + const parts = Array.isArray(toolResponse.responseParts) + ? toolResponse.responseParts + : [toolResponse.responseParts]; + for (const part of parts) { + if (typeof part === 'string') { + toolResponseParts.push({ text: part }); + } else if (part) { + toolResponseParts.push(part); + } + } + } + } + // If all tool calls failed, inform the model so it can re-evaluate. + if (functionCalls.length > 0 && toolResponseParts.length === 0) { + toolResponseParts.push({ + text: 'All tool calls failed. Please analyze the errors and try an alternative approach.', + }); + } + + return [{ role: 'user', parts: toolResponseParts }]; + } + + private async createChatObject(context: ContextState) { + if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) { + throw new Error( + 'PromptConfig must have either `systemPrompt` or `initialMessages` defined.', + ); + } + if (this.promptConfig.systemPrompt && this.promptConfig.initialMessages) { + throw new Error( + 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', + ); + } + + const envParts = await getEnvironmentContext(this.runtimeContext); + const envHistory: Content[] = [ + { role: 'user', parts: envParts }, + { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] }, + ]; + + const start_history = [ + ...envHistory, + ...(this.promptConfig.initialMessages ?? []), + ]; + + const systemInstruction = this.promptConfig.systemPrompt + ? this.buildChatSystemPrompt(context) + : undefined; + + try { + const generationConfig: GenerateContentConfig & { + systemInstruction?: string | Content; + } = { + temperature: this.modelConfig.temp, + topP: this.modelConfig.top_p, + }; + + if (systemInstruction) { + generationConfig.systemInstruction = systemInstruction; + } + + const contentGenerator = await createContentGenerator( + this.runtimeContext.getContentGeneratorConfig(), + this.runtimeContext, + this.runtimeContext.getSessionId(), + ); + + this.runtimeContext.setModel(this.modelConfig.model); + + return new GeminiChat( + this.runtimeContext, + contentGenerator, + generationConfig, + start_history, + ); + } catch (error) { + await reportError( + error, + 'Error initializing Gemini chat session.', + start_history, + 'startChat', + ); + // The calling function will handle the undefined return. + return undefined; + } + } + + /** + * Returns an array of FunctionDeclaration objects for tools that are local to the subagent's scope. + * Currently, this includes the `self.emitvalue` tool for emitting variables. + * @returns An array of `FunctionDeclaration` objects. + */ + private getScopeLocalFuncDefs() { + const emitValueTool: FunctionDeclaration = { + name: 'self.emitvalue', + description: `* This tool emits A SINGLE return value from this execution, such that it can be collected and presented to the calling function. + * You can only emit ONE VALUE each time you call this tool. You are expected to call this tool MULTIPLE TIMES if you have MULTIPLE OUTPUTS.`, + parameters: { + type: Type.OBJECT, + properties: { + emit_variable_name: { + description: 'This is the name of the variable to be returned.', + type: Type.STRING, + }, + emit_variable_value: { + description: + 'This is the _value_ to be returned for this variable.', + type: Type.STRING, + }, + }, + required: ['emit_variable_name', 'emit_variable_value'], + }, + }; + + return [emitValueTool]; + } + + /** + * Builds the system prompt for the chat based on the provided configurations. + * It templates the base system prompt and appends instructions for emitting + * variables if an `OutputConfig` is provided. + * @param {ContextState} context - The context for templating. + * @returns {string} The complete system prompt. + */ + private buildChatSystemPrompt(context: ContextState): string { + if (!this.promptConfig.systemPrompt) { + // This should ideally be caught in createChatObject, but serves as a safeguard. + return ''; + } + + let finalPrompt = templateString(this.promptConfig.systemPrompt, context); + + // Add instructions for emitting variables if needed. + if (this.outputConfig && this.outputConfig.outputs) { + let outputInstructions = + '\n\nAfter you have achieved all other goals, you MUST emit the required output variables. For each expected output, make one final call to the `self.emitvalue` tool.'; + + for (const [key, value] of Object.entries(this.outputConfig.outputs)) { + outputInstructions += `\n* Use 'self.emitvalue' to emit the '${key}' key, with a value described as: '${value}'`; + } + finalPrompt += outputInstructions; + } + + // Add general non-interactive instructions. + finalPrompt += ` + +Important Rules: + * You are running in a non-interactive mode. You CANNOT ask the user for input or clarification. You must proceed with the information you have. + * Once you believe all goals have been met and all required outputs have been emitted, stop calling tools.`; + + return finalPrompt; + } +} diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 73b427d4..c77fab8c 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -365,6 +365,22 @@ export class ToolRegistry { return declarations; } + /** + * Retrieves a filtered list of tool schemas based on a list of tool names. + * @param toolNames - An array of tool names to include. + * @returns An array of FunctionDeclarations for the specified tools. + */ + getFunctionDeclarationsFiltered(toolNames: string[]): FunctionDeclaration[] { + const declarations: FunctionDeclaration[] = []; + for (const name of toolNames) { + const tool = this.tools.get(name); + if (tool) { + declarations.push(tool.schema); + } + } + return declarations; + } + /** * Returns an array of all registered and discovered tool instances. */ diff --git a/packages/core/src/utils/environmentContext.test.ts b/packages/core/src/utils/environmentContext.test.ts new file mode 100644 index 00000000..656fb63f --- /dev/null +++ b/packages/core/src/utils/environmentContext.test.ts @@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { + getEnvironmentContext, + getDirectoryContextString, +} from './environmentContext.js'; +import { Config } from '../config/config.js'; +import { getFolderStructure } from './getFolderStructure.js'; + +vi.mock('../config/config.js'); +vi.mock('./getFolderStructure.js', () => ({ + getFolderStructure: vi.fn(), +})); +vi.mock('../tools/read-many-files.js'); + +describe('getDirectoryContextString', () => { + let mockConfig: Partial; + + beforeEach(() => { + mockConfig = { + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi.fn().mockReturnValue(['/test/dir']), + }), + getFileService: vi.fn(), + }; + vi.mocked(getFolderStructure).mockResolvedValue('Mock Folder Structure'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should return context string for a single directory', async () => { + const contextString = await getDirectoryContextString(mockConfig as Config); + expect(contextString).toContain( + "I'm currently working in the directory: /test/dir", + ); + expect(contextString).toContain( + 'Here is the folder structure of the current working directories:\n\nMock Folder Structure', + ); + }); + + it('should return context string for multiple directories', async () => { + ( + vi.mocked(mockConfig.getWorkspaceContext!)().getDirectories as Mock + ).mockReturnValue(['/test/dir1', '/test/dir2']); + vi.mocked(getFolderStructure) + .mockResolvedValueOnce('Structure 1') + .mockResolvedValueOnce('Structure 2'); + + const contextString = await getDirectoryContextString(mockConfig as Config); + expect(contextString).toContain( + "I'm currently working in the following directories:\n - /test/dir1\n - /test/dir2", + ); + expect(contextString).toContain( + 'Here is the folder structure of the current working directories:\n\nStructure 1\nStructure 2', + ); + }); +}); + +describe('getEnvironmentContext', () => { + let mockConfig: Partial; + let mockToolRegistry: { getTool: Mock }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-08-05T12:00:00Z')); + + mockToolRegistry = { + getTool: vi.fn(), + }; + + mockConfig = { + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi.fn().mockReturnValue(['/test/dir']), + }), + getFileService: vi.fn(), + getFullContext: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry), + }; + + vi.mocked(getFolderStructure).mockResolvedValue('Mock Folder Structure'); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it('should return basic environment context for a single directory', async () => { + const parts = await getEnvironmentContext(mockConfig as Config); + + expect(parts.length).toBe(1); + const context = parts[0].text; + + expect(context).toContain("Today's date is Tuesday, August 5, 2025"); + expect(context).toContain(`My operating system is: ${process.platform}`); + expect(context).toContain( + "I'm currently working in the directory: /test/dir", + ); + expect(context).toContain( + 'Here is the folder structure of the current working directories:\n\nMock Folder Structure', + ); + expect(getFolderStructure).toHaveBeenCalledWith('/test/dir', { + fileService: undefined, + }); + }); + + it('should return basic environment context for multiple directories', async () => { + ( + vi.mocked(mockConfig.getWorkspaceContext!)().getDirectories as Mock + ).mockReturnValue(['/test/dir1', '/test/dir2']); + vi.mocked(getFolderStructure) + .mockResolvedValueOnce('Structure 1') + .mockResolvedValueOnce('Structure 2'); + + const parts = await getEnvironmentContext(mockConfig as Config); + + expect(parts.length).toBe(1); + const context = parts[0].text; + + expect(context).toContain( + "I'm currently working in the following directories:\n - /test/dir1\n - /test/dir2", + ); + expect(context).toContain( + 'Here is the folder structure of the current working directories:\n\nStructure 1\nStructure 2', + ); + expect(getFolderStructure).toHaveBeenCalledTimes(2); + }); + + it('should include full file context when getFullContext is true', async () => { + mockConfig.getFullContext = vi.fn().mockReturnValue(true); + const mockReadManyFilesTool = { + build: vi.fn().mockReturnValue({ + execute: vi + .fn() + .mockResolvedValue({ llmContent: 'Full file content here' }), + }), + }; + mockToolRegistry.getTool.mockReturnValue(mockReadManyFilesTool); + + const parts = await getEnvironmentContext(mockConfig as Config); + + expect(parts.length).toBe(2); + expect(parts[1].text).toBe( + '\n--- Full File Context ---\nFull file content here', + ); + expect(mockToolRegistry.getTool).toHaveBeenCalledWith('read_many_files'); + expect(mockReadManyFilesTool.build).toHaveBeenCalledWith({ + paths: ['**/*'], + useDefaultExcludes: true, + }); + }); + + it('should handle read_many_files returning no content', async () => { + mockConfig.getFullContext = vi.fn().mockReturnValue(true); + const mockReadManyFilesTool = { + build: vi.fn().mockReturnValue({ + execute: vi.fn().mockResolvedValue({ llmContent: '' }), + }), + }; + mockToolRegistry.getTool.mockReturnValue(mockReadManyFilesTool); + + const parts = await getEnvironmentContext(mockConfig as Config); + + expect(parts.length).toBe(1); // No extra part added + }); + + it('should handle read_many_files tool not being found', async () => { + mockConfig.getFullContext = vi.fn().mockReturnValue(true); + mockToolRegistry.getTool.mockReturnValue(null); + + const parts = await getEnvironmentContext(mockConfig as Config); + + expect(parts.length).toBe(1); // No extra part added + }); + + it('should handle errors when reading full file context', async () => { + mockConfig.getFullContext = vi.fn().mockReturnValue(true); + const mockReadManyFilesTool = { + build: vi.fn().mockReturnValue({ + execute: vi.fn().mockRejectedValue(new Error('Read error')), + }), + }; + mockToolRegistry.getTool.mockReturnValue(mockReadManyFilesTool); + + const parts = await getEnvironmentContext(mockConfig as Config); + + expect(parts.length).toBe(2); + expect(parts[1].text).toBe('\n--- Error reading full file context ---'); + }); +}); diff --git a/packages/core/src/utils/environmentContext.ts b/packages/core/src/utils/environmentContext.ts new file mode 100644 index 00000000..79fb6049 --- /dev/null +++ b/packages/core/src/utils/environmentContext.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Part } from '@google/genai'; +import { Config } from '../config/config.js'; +import { getFolderStructure } from './getFolderStructure.js'; + +/** + * Generates a string describing the current workspace directories and their structures. + * @param {Config} config - The runtime configuration and services. + * @returns {Promise} A promise that resolves to the directory context string. + */ +export async function getDirectoryContextString( + config: Config, +): Promise { + const workspaceContext = config.getWorkspaceContext(); + const workspaceDirectories = workspaceContext.getDirectories(); + + const folderStructures = await Promise.all( + workspaceDirectories.map((dir) => + getFolderStructure(dir, { + fileService: config.getFileService(), + }), + ), + ); + + const folderStructure = folderStructures.join('\n'); + + let workingDirPreamble: string; + if (workspaceDirectories.length === 1) { + workingDirPreamble = `I'm currently working in the directory: ${workspaceDirectories[0]}`; + } else { + const dirList = workspaceDirectories.map((dir) => ` - ${dir}`).join('\n'); + workingDirPreamble = `I'm currently working in the following directories:\n${dirList}`; + } + + return `${workingDirPreamble} +Here is the folder structure of the current working directories: + +${folderStructure}`; +} + +/** + * Retrieves environment-related information to be included in the chat context. + * This includes the current working directory, date, operating system, and folder structure. + * Optionally, it can also include the full file context if enabled. + * @param {Config} config - The runtime configuration and services. + * @returns A promise that resolves to an array of `Part` objects containing environment information. + */ +export async function getEnvironmentContext(config: Config): Promise { + const today = new Date().toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const platform = process.platform; + const directoryContext = await getDirectoryContextString(config); + + const context = ` +This is the Gemini CLI. We are setting up the context for our chat. +Today's date is ${today}. +My operating system is: ${platform} +${directoryContext} + `.trim(); + + const initialParts: Part[] = [{ text: context }]; + const toolRegistry = await config.getToolRegistry(); + + // Add full file context if the flag is set + if (config.getFullContext()) { + try { + const readManyFilesTool = toolRegistry.getTool('read_many_files'); + if (readManyFilesTool) { + const invocation = readManyFilesTool.build({ + paths: ['**/*'], // Read everything recursively + useDefaultExcludes: true, // Use default excludes + }); + + // Read all files in the target directory + const result = await invocation.execute(AbortSignal.timeout(30000)); + if (result.llmContent) { + initialParts.push({ + text: `\n--- Full File Context ---\n${result.llmContent}`, + }); + } else { + console.warn( + 'Full context requested, but read_many_files returned no content.', + ); + } + } else { + console.warn( + 'Full context requested, but read_many_files tool not found.', + ); + } + } catch (error) { + // Not using reportError here as it's a startup/config phase, not a chat/generation phase error. + console.error('Error reading full file context:', error); + initialParts.push({ + text: '\n--- Error reading full file context ---', + }); + } + } + + return initialParts; +} From 6ae75c9f32a968efa50857a8f24b958a58a84fd6 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Thu, 7 Aug 2025 07:34:40 -0700 Subject: [PATCH 033/107] Add a context percentage threshold setting for auto compression (#5721) --- docs/cli/configuration.md | 12 +++ packages/cli/src/config/config.test.ts | 39 ++++++++ packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settings.test.ts | 118 +++++++++++++++++++++++ packages/cli/src/config/settings.ts | 21 ++++ packages/core/src/config/config.ts | 11 +++ packages/core/src/core/client.test.ts | 10 +- packages/core/src/core/client.ts | 14 ++- 8 files changed, 219 insertions(+), 7 deletions(-) diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index 5c917a3f..9fc74adb 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -268,6 +268,18 @@ In addition to a project settings file, a project's `.gemini` directory can cont "loadMemoryFromIncludeDirectories": true ``` +- **`chatCompression`** (object): + - **Description:** Controls the settings for chat history compression, both automatic and + when manually invoked through the /compress command. + - **Properties:** + - **`contextPercentageThreshold`** (number): A value between 0 and 1 that specifies the token threshold for compression as a percentage of the model's total token limit. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. + - **Example:** + ```json + "chatCompression": { + "contextPercentageThreshold": 0.6 + } + ``` + ### Example `settings.json`: ```json diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 6a7e3b57..b670fbc8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1123,3 +1123,42 @@ describe('loadCliConfig with includeDirectories', () => { ); }); }); + +describe('loadCliConfig chatCompression', () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should pass chatCompression settings to the core config', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { + chatCompression: { + contextPercentageThreshold: 0.5, + }, + }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getChatCompression()).toEqual({ + contextPercentageThreshold: 0.5, + }); + }); + + it('should have undefined chatCompression if not in settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = {}; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getChatCompression()).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 2c942c08..a47d8301 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -482,6 +482,7 @@ export async function loadCliConfig( summarizeToolOutput: settings.summarizeToolOutput, ideMode, ideModeFeature, + chatCompression: settings.chatCompression, folderTrustFeature, }); } diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index d0266720..f68b13e3 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -113,6 +113,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); expect(settings.errors.length).toBe(0); }); @@ -147,6 +148,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -181,6 +183,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -213,6 +216,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -251,6 +255,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -301,6 +306,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); @@ -622,6 +628,116 @@ describe('Settings Loading and Merging', () => { expect(settings.merged.mcpServers).toEqual({}); }); + it('should merge chatCompression settings, with workspace taking precedence', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + chatCompression: { contextPercentageThreshold: 0.5 }, + }; + const workspaceSettingsContent = { + chatCompression: { contextPercentageThreshold: 0.8 }, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.user.settings.chatCompression).toEqual({ + contextPercentageThreshold: 0.5, + }); + expect(settings.workspace.settings.chatCompression).toEqual({ + contextPercentageThreshold: 0.8, + }); + expect(settings.merged.chatCompression).toEqual({ + contextPercentageThreshold: 0.8, + }); + }); + + it('should handle chatCompression when only in user settings', () => { + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + chatCompression: { contextPercentageThreshold: 0.5 }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.chatCompression).toEqual({ + contextPercentageThreshold: 0.5, + }); + }); + + it('should have chatCompression as an empty object if not in any settings file', () => { + (mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist + (fs.readFileSync as Mock).mockReturnValue('{}'); + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.chatCompression).toEqual({}); + }); + + it('should ignore chatCompression if contextPercentageThreshold is invalid', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + (mockFsExistsSync as Mock).mockImplementation( + (p: fs.PathLike) => p === USER_SETTINGS_PATH, + ); + const userSettingsContent = { + chatCompression: { contextPercentageThreshold: 1.5 }, + }; + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.chatCompression).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + 'Invalid value for chatCompression.contextPercentageThreshold: "1.5". Please use a value between 0 and 1. Using default compression settings.', + ); + warnSpy.mockRestore(); + }); + + it('should deep merge chatCompression settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + chatCompression: { contextPercentageThreshold: 0.5 }, + }; + const workspaceSettingsContent = { + chatCompression: {}, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + + expect(settings.merged.chatCompression).toEqual({ + contextPercentageThreshold: 0.5, + }); + }); + it('should merge includeDirectories from all scopes', () => { (mockFsExistsSync as Mock).mockReturnValue(true); const systemSettingsContent = { @@ -695,6 +811,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); // Check that error objects are populated in settings.errors @@ -1132,6 +1249,7 @@ describe('Settings Loading and Merging', () => { customThemes: {}, mcpServers: {}, includeDirectories: [], + chatCompression: {}, }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 64500845..8005ad65 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -13,6 +13,7 @@ import { GEMINI_CONFIG_DIR as GEMINI_DIR, getErrorMessage, BugCommandSettings, + ChatCompressionSettings, TelemetrySettings, AuthType, } from '@google/gemini-cli-core'; @@ -134,6 +135,8 @@ export interface Settings { includeDirectories?: string[]; loadMemoryFromIncludeDirectories?: boolean; + + chatCompression?: ChatCompressionSettings; } export interface SettingsError { @@ -194,6 +197,11 @@ export class LoadedSettings { ...(user.includeDirectories || []), ...(workspace.includeDirectories || []), ], + chatCompression: { + ...(system.chatCompression || {}), + ...(user.chatCompression || {}), + ...(workspace.chatCompression || {}), + }, }; } @@ -482,6 +490,19 @@ export function loadSettings(workspaceDir: string): LoadedSettings { settingsErrors, ); + // Validate chatCompression settings + const chatCompression = loadedSettings.merged.chatCompression; + const threshold = chatCompression?.contextPercentageThreshold; + if ( + threshold != null && + (typeof threshold !== 'number' || threshold < 0 || threshold > 1) + ) { + console.warn( + `Invalid value for chatCompression.contextPercentageThreshold: "${threshold}". Please use a value between 0 and 1. Using default compression settings.`, + ); + delete loadedSettings.merged.chatCompression; + } + // Load environment with merged settings loadEnvironment(loadedSettings.merged); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 005573da..4848bfb6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -69,6 +69,10 @@ export interface BugCommandSettings { urlTemplate: string; } +export interface ChatCompressionSettings { + contextPercentageThreshold?: number; +} + export interface SummarizeToolOutputSettings { tokenBudget?: number; } @@ -191,6 +195,7 @@ export interface ConfigParameters { folderTrustFeature?: boolean; ideMode?: boolean; loadMemoryFromIncludeDirectories?: boolean; + chatCompression?: ChatCompressionSettings; } export class Config { @@ -252,6 +257,7 @@ export class Config { | undefined; private readonly experimentalAcp: boolean = false; private readonly loadMemoryFromIncludeDirectories: boolean = false; + private readonly chatCompression: ChatCompressionSettings | undefined; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -316,6 +322,7 @@ export class Config { } this.loadMemoryFromIncludeDirectories = params.loadMemoryFromIncludeDirectories ?? false; + this.chatCompression = params.chatCompression; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -667,6 +674,10 @@ export class Config { return this.ideClient; } + getChatCompression(): ChatCompressionSettings | undefined { + return this.chatCompression; + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir); diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 1e39758a..ff901a8b 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -206,6 +206,7 @@ describe('Gemini Client (client.ts)', () => { }), getGeminiClient: vi.fn(), setFallbackMode: vi.fn(), + getChatCompression: vi.fn().mockReturnValue(undefined), }; const MockedConfig = vi.mocked(Config, true); MockedConfig.mockImplementation( @@ -531,14 +532,19 @@ describe('Gemini Client (client.ts)', () => { expect(newChat).toBe(initialChat); }); - it('should trigger summarization if token count is at threshold', async () => { + it('should trigger summarization if token count is at threshold with contextPercentageThreshold setting', async () => { const MOCKED_TOKEN_LIMIT = 1000; + const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5; vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT); + vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({ + contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD, + }); mockGetHistory.mockReturnValue([ { role: 'user', parts: [{ text: '...history...' }] }, ]); - const originalTokenCount = 1000 * 0.7; + const originalTokenCount = + MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD; const newTokenCount = 100; mockCountTokens diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index a16a72cc..13e60039 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -596,12 +596,16 @@ export class GeminiClient { return null; } + const contextPercentageThreshold = + this.config.getChatCompression()?.contextPercentageThreshold; + // Don't compress if not forced and we are under the limit. - if ( - !force && - originalTokenCount < this.COMPRESSION_TOKEN_THRESHOLD * tokenLimit(model) - ) { - return null; + if (!force) { + const threshold = + contextPercentageThreshold ?? this.COMPRESSION_TOKEN_THRESHOLD; + if (originalTokenCount < threshold * tokenLimit(model)) { + return null; + } } let compressBeforeIndex = findIndexAfterFraction( From 8d848dca4a52d169b3dfea2f66e7e5f69ee5e45c Mon Sep 17 00:00:00 2001 From: Lee James <40045512+leehagoodjames@users.noreply.github.com> Date: Thu, 7 Aug 2025 12:00:46 -0400 Subject: [PATCH 034/107] feat: open repo secrets page in addition to README (#5684) --- .../ui/commands/setupGithubCommand.test.ts | 9 ++++- .../cli/src/ui/commands/setupGithubCommand.ts | 29 +++++++++++++- .../cli/src/ui/utils/commandUtils.test.ts | 39 +++++++++++++++++++ packages/cli/src/ui/utils/commandUtils.ts | 26 ++++++++++++- packages/cli/src/utils/gitUtils.test.ts | 34 ++++++++++++++++ packages/cli/src/utils/gitUtils.ts | 25 ++++++++++++ 6 files changed, 158 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/commands/setupGithubCommand.test.ts b/packages/cli/src/ui/commands/setupGithubCommand.test.ts index 6417c60a..be0a657f 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.test.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.test.ts @@ -18,6 +18,7 @@ vi.mock('../../utils/gitUtils.js', () => ({ isGitHubRepository: vi.fn(), getGitRepoRoot: vi.fn(), getLatestGitHubRelease: vi.fn(), + getGitHubRepoInfo: vi.fn(), })); describe('setupGithubCommand', async () => { @@ -30,7 +31,9 @@ describe('setupGithubCommand', async () => { }); it('returns a tool action to download github workflows and handles paths', async () => { - const fakeRepoRoot = '/github.com/fake/repo/root'; + const fakeRepoOwner = 'fake'; + const fakeRepoName = 'repo'; + const fakeRepoRoot = `/github.com/${fakeRepoOwner}/${fakeRepoName}/root`; const fakeReleaseVersion = 'v1.2.3'; vi.mocked(gitUtils.isGitHubRepository).mockReturnValueOnce(true); @@ -38,6 +41,10 @@ describe('setupGithubCommand', async () => { vi.mocked(gitUtils.getLatestGitHubRelease).mockResolvedValueOnce( fakeReleaseVersion, ); + vi.mocked(gitUtils.getGitHubRepoInfo).mockReturnValue({ + owner: fakeRepoOwner, + repo: fakeRepoName, + }); const result = (await setupGithubCommand.action?.( {} as CommandContext, diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 1b5b3277..84d6b5af 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -11,6 +11,7 @@ import { getGitRepoRoot, getLatestGitHubRelease, isGitHubRepository, + getGitHubRepoInfo, } from '../../utils/gitUtils.js'; import { @@ -18,6 +19,27 @@ import { SlashCommand, SlashCommandActionReturn, } from './types.js'; +import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; + +// Generate OS-specific commands to open the GitHub pages needed for setup. +function getOpenUrlsCommands(readmeUrl: string): string[] { + // Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc + const openCmd = getUrlOpenCommand(); + + // Build a list of URLs to open + const urlsToOpen = [readmeUrl]; + + const repoInfo = getGitHubRepoInfo(); + if (repoInfo) { + urlsToOpen.push( + `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`, + ); + } + + // Create and join the individual commands + const commands = urlsToOpen.map((url) => `${openCmd} "${url}"`); + return commands; +} export const setupGithubCommand: SlashCommand = { name: 'setup-github', @@ -71,11 +93,14 @@ export const setupGithubCommand: SlashCommand = { commands.push(curlCommand); } + const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; + commands.push( - `echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start (skipping the /setup-github step) to complete setup."`, - `open https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`, + `echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`, ); + commands.push(...getOpenUrlsCommands(readmeUrl)); + const command = `(${commands.join(' && ')})`; return { type: 'tool', diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 4bd48cee..db333e72 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -11,6 +11,7 @@ import { isAtCommand, isSlashCommand, copyToClipboard, + getUrlOpenCommand, } from './commandUtils.js'; // Mock child_process @@ -342,4 +343,42 @@ describe('commandUtils', () => { }); }); }); + + describe('getUrlOpenCommand', () => { + describe('on macOS (darwin)', () => { + beforeEach(() => { + mockProcess.platform = 'darwin'; + }); + it('should return open', () => { + expect(getUrlOpenCommand()).toBe('open'); + }); + }); + + describe('on Windows (win32)', () => { + beforeEach(() => { + mockProcess.platform = 'win32'; + }); + it('should return start', () => { + expect(getUrlOpenCommand()).toBe('start'); + }); + }); + + describe('on Linux (linux)', () => { + beforeEach(() => { + mockProcess.platform = 'linux'; + }); + it('should return xdg-open', () => { + expect(getUrlOpenCommand()).toBe('xdg-open'); + }); + }); + + describe('on unmatched OS', () => { + beforeEach(() => { + mockProcess.platform = 'unmatched'; + }); + it('should return xdg-open', () => { + expect(getUrlOpenCommand()).toBe('xdg-open'); + }); + }); + }); }); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 4280388f..80ed51ae 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -27,7 +27,7 @@ export const isAtCommand = (query: string): boolean => */ export const isSlashCommand = (query: string): boolean => query.startsWith('/'); -//Copies a string snippet to the clipboard for different platforms +// Copies a string snippet to the clipboard for different platforms export const copyToClipboard = async (text: string): Promise => { const run = (cmd: string, args: string[]) => new Promise((resolve, reject) => { @@ -80,3 +80,27 @@ export const copyToClipboard = async (text: string): Promise => { throw new Error(`Unsupported platform: ${process.platform}`); } }; + +export const getUrlOpenCommand = (): string => { + // --- Determine the OS-specific command to open URLs --- + let openCmd: string; + switch (process.platform) { + case 'darwin': + openCmd = 'open'; + break; + case 'win32': + openCmd = 'start'; + break; + case 'linux': + openCmd = 'xdg-open'; + break; + default: + // Default to xdg-open, which appears to be supported for the less popular operating systems. + openCmd = 'xdg-open'; + console.warn( + `Unknown platform: ${process.platform}. Attempting to open URLs with: ${openCmd}.`, + ); + break; + } + return openCmd; +}; diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts index 4a29f589..7a5f210c 100644 --- a/packages/cli/src/utils/gitUtils.test.ts +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -10,6 +10,7 @@ import { isGitHubRepository, getGitRepoRoot, getLatestGitHubRelease, + getGitHubRepoInfo, } from './gitUtils.js'; vi.mock('child_process'); @@ -44,6 +45,39 @@ describe('isGitHubRepository', async () => { }); }); +describe('getGitHubRepoInfo', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if github repo info cannot be determined', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/oops/); + }); + + it('throws an error if owner/repo could not be determined', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(''); + expect(() => { + getGitHubRepoInfo(); + }).toThrowError(/Owner & repo could not be extracted from remote URL/); + }); + + it('returns the owner and repo', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce( + 'https://github.com/owner/repo.git ', + ); + expect(getGitHubRepoInfo()).toEqual({ owner: 'owner', repo: 'repo' }); + }); +}); + describe('getGitRepoRoot', async () => { beforeEach(() => { vi.resetAllMocks(); diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index 30ca2245..f5f9cb92 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -91,3 +91,28 @@ export const getLatestGitHubRelease = async ( ); } }; + +/** + * getGitHubRepoInfo returns the owner and repository for a GitHub repo. + * @returns the owner and repository of the github repo. + * @throws error if the exec command fails. + */ +export function getGitHubRepoInfo(): { owner: string; repo: string } { + const remoteUrl = execSync('git remote get-url origin', { + encoding: 'utf-8', + }).trim(); + + // Matches either https://github.com/owner/repo.git or git@github.com:owner/repo.git + const match = remoteUrl.match( + /(?:https?:\/\/|git@)github\.com(?::|\/)([^/]+)\/([^/]+?)(?:\.git)?$/, + ); + + // If the regex fails match, throw an error. + if (!match || !match[1] || !match[2]) { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + return { owner: match[1], repo: match[2] }; +} From 0d65baf9283138da56cdf08b00058ab3cf8cbaf9 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 7 Aug 2025 09:18:53 -0700 Subject: [PATCH 035/107] Fix(core): Use Flash for next speaker check (#5786) --- packages/core/src/utils/nextSpeakerChecker.test.ts | 4 ++-- packages/core/src/utils/nextSpeakerChecker.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/nextSpeakerChecker.test.ts b/packages/core/src/utils/nextSpeakerChecker.test.ts index 70d6023f..9141105f 100644 --- a/packages/core/src/utils/nextSpeakerChecker.test.ts +++ b/packages/core/src/utils/nextSpeakerChecker.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, Mock, afterEach } from 'vitest'; import { Content, GoogleGenAI, Models } from '@google/genai'; -import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js'; +import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { GeminiClient } from '../core/client.js'; import { Config } from '../config/config.js'; import { checkNextSpeaker, NextSpeakerResponse } from './nextSpeakerChecker.js'; @@ -248,6 +248,6 @@ describe('checkNextSpeaker', () => { expect(mockGeminiClient.generateJson).toHaveBeenCalled(); const generateJsonCall = (mockGeminiClient.generateJson as Mock).mock .calls[0]; - expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); + expect(generateJsonCall[3]).toBe(DEFAULT_GEMINI_FLASH_MODEL); }); }); diff --git a/packages/core/src/utils/nextSpeakerChecker.ts b/packages/core/src/utils/nextSpeakerChecker.ts index a0d735b0..8497db61 100644 --- a/packages/core/src/utils/nextSpeakerChecker.ts +++ b/packages/core/src/utils/nextSpeakerChecker.ts @@ -5,7 +5,7 @@ */ import { Content, SchemaUnion, Type } from '@google/genai'; -import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js'; +import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { GeminiClient } from '../core/client.js'; import { GeminiChat } from '../core/geminiChat.js'; import { isFunctionResponse } from './messageInspectors.js'; @@ -112,7 +112,7 @@ export async function checkNextSpeaker( contents, RESPONSE_SCHEMA, abortSignal, - DEFAULT_GEMINI_FLASH_LITE_MODEL, + DEFAULT_GEMINI_FLASH_MODEL, )) as unknown as NextSpeakerResponse; if ( From 8bac9e7d048c7ff97f0942b23edb0167ee6ca83e Mon Sep 17 00:00:00 2001 From: joshualitt Date: Thu, 7 Aug 2025 10:05:37 -0700 Subject: [PATCH 036/107] Migrate EditTool, GrepTool, and GlobTool to DeclarativeTool (#5744) --- packages/core/src/tools/edit.test.ts | 162 +++++++------ packages/core/src/tools/edit.ts | 300 ++++++++++++------------ packages/core/src/tools/glob.test.ts | 34 ++- packages/core/src/tools/glob.ts | 331 ++++++++++++++------------- packages/core/src/tools/grep.test.ts | 71 +++--- packages/core/src/tools/grep.ts | 266 ++++++++++++--------- packages/core/src/tools/read-file.ts | 20 +- packages/core/src/tools/tools.ts | 28 +++ 8 files changed, 649 insertions(+), 563 deletions(-) diff --git a/packages/core/src/tools/edit.test.ts b/packages/core/src/tools/edit.test.ts index 3bfa023e..3e0dba61 100644 --- a/packages/core/src/tools/edit.test.ts +++ b/packages/core/src/tools/edit.test.ts @@ -27,7 +27,7 @@ vi.mock('../utils/editor.js', () => ({ })); import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest'; -import { EditTool, EditToolParams } from './edit.js'; +import { applyReplacement, EditTool, EditToolParams } from './edit.js'; import { FileDiff, ToolConfirmationOutcome } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import path from 'path'; @@ -155,45 +155,30 @@ describe('EditTool', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - describe('_applyReplacement', () => { - // Access private method for testing - // Note: `tool` is initialized in `beforeEach` of the parent describe block + describe('applyReplacement', () => { it('should return newString if isNewFile is true', () => { - expect((tool as any)._applyReplacement(null, 'old', 'new', true)).toBe( - 'new', - ); - expect( - (tool as any)._applyReplacement('existing', 'old', 'new', true), - ).toBe('new'); + expect(applyReplacement(null, 'old', 'new', true)).toBe('new'); + expect(applyReplacement('existing', 'old', 'new', true)).toBe('new'); }); it('should return newString if currentContent is null and oldString is empty (defensive)', () => { - expect((tool as any)._applyReplacement(null, '', 'new', false)).toBe( - 'new', - ); + expect(applyReplacement(null, '', 'new', false)).toBe('new'); }); it('should return empty string if currentContent is null and oldString is not empty (defensive)', () => { - expect((tool as any)._applyReplacement(null, 'old', 'new', false)).toBe( - '', - ); + expect(applyReplacement(null, 'old', 'new', false)).toBe(''); }); it('should replace oldString with newString in currentContent', () => { - expect( - (tool as any)._applyReplacement( - 'hello old world old', - 'old', - 'new', - false, - ), - ).toBe('hello new world new'); + expect(applyReplacement('hello old world old', 'old', 'new', false)).toBe( + 'hello new world new', + ); }); it('should return currentContent if oldString is empty and not a new file', () => { - expect( - (tool as any)._applyReplacement('hello world', '', 'new', false), - ).toBe('hello world'); + expect(applyReplacement('hello world', '', 'new', false)).toBe( + 'hello world', + ); }); }); @@ -239,15 +224,13 @@ describe('EditTool', () => { filePath = path.join(rootDir, testFile); }); - it('should return false if params are invalid', async () => { + it('should throw an error if params are invalid', async () => { const params: EditToolParams = { file_path: 'relative.txt', old_string: 'old', new_string: 'new', }; - expect( - await tool.shouldConfirmExecute(params, new AbortController().signal), - ).toBe(false); + expect(() => tool.build(params)).toThrow(); }); it('should request confirmation for valid edit', async () => { @@ -259,8 +242,8 @@ describe('EditTool', () => { }; // ensureCorrectEdit will be called by shouldConfirmExecute mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 1 }); - const confirmation = await tool.shouldConfirmExecute( - params, + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmation).toEqual( @@ -280,9 +263,11 @@ describe('EditTool', () => { new_string: 'new', }; mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 0 }); - expect( - await tool.shouldConfirmExecute(params, new AbortController().signal), - ).toBe(false); + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmation).toBe(false); }); it('should return false if multiple occurrences of old_string are found (ensureCorrectEdit returns > 1)', async () => { @@ -293,9 +278,11 @@ describe('EditTool', () => { new_string: 'new', }; mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 2 }); - expect( - await tool.shouldConfirmExecute(params, new AbortController().signal), - ).toBe(false); + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + expect(confirmation).toBe(false); }); it('should request confirmation for creating a new file (empty old_string)', async () => { @@ -310,8 +297,8 @@ describe('EditTool', () => { // as shouldConfirmExecute handles this for diff generation. // If it is called, it should return 0 occurrences for a new file. mockEnsureCorrectEdit.mockResolvedValueOnce({ params, occurrences: 0 }); - const confirmation = await tool.shouldConfirmExecute( - params, + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); expect(confirmation).toEqual( @@ -358,9 +345,8 @@ describe('EditTool', () => { }; }, ); - - const confirmation = (await tool.shouldConfirmExecute( - params, + const invocation = tool.build(params); + const confirmation = (await invocation.shouldConfirmExecute( new AbortController().signal, )) as FileDiff; @@ -408,15 +394,13 @@ describe('EditTool', () => { }); }); - it('should return error if params are invalid', async () => { + it('should throw error if params are invalid', async () => { const params: EditToolParams = { file_path: 'relative.txt', old_string: 'old', new_string: 'new', }; - const result = await tool.execute(params, new AbortController().signal); - expect(result.llmContent).toMatch(/Error: Invalid parameters provided/); - expect(result.returnDisplay).toMatch(/Error: File path must be absolute/); + expect(() => tool.build(params)).toThrow(/File path must be absolute/); }); it('should edit an existing file and return diff with fileName', async () => { @@ -433,12 +417,8 @@ describe('EditTool', () => { // ensureCorrectEdit is NOT called by calculateEdit, only by shouldConfirmExecute // So, the default mockEnsureCorrectEdit should correctly return 1 occurrence for 'old' in initialContent - // Simulate confirmation by setting shouldAlwaysEdit - (tool as any).shouldAlwaysEdit = true; - - const result = await tool.execute(params, new AbortController().signal); - - (tool as any).shouldAlwaysEdit = false; // Reset for other tests + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch(/Successfully modified file/); expect(fs.readFileSync(filePath, 'utf8')).toBe(newContent); @@ -461,7 +441,8 @@ describe('EditTool', () => { (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( ApprovalMode.AUTO_EDIT, ); - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch(/Created new file/); expect(fs.existsSync(newFilePath)).toBe(true); @@ -477,7 +458,8 @@ describe('EditTool', () => { new_string: 'replacement', }; // The default mockEnsureCorrectEdit will return 0 occurrences for 'nonexistent' - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch( /0 occurrences found for old_string in/, ); @@ -494,7 +476,8 @@ describe('EditTool', () => { new_string: 'new', }; // The default mockEnsureCorrectEdit will return 2 occurrences for 'old' - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch( /Expected 1 occurrence but found 2 for old_string in file/, ); @@ -512,12 +495,8 @@ describe('EditTool', () => { expected_replacements: 3, }; - // Simulate confirmation by setting shouldAlwaysEdit - (tool as any).shouldAlwaysEdit = true; - - const result = await tool.execute(params, new AbortController().signal); - - (tool as any).shouldAlwaysEdit = false; // Reset for other tests + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch(/Successfully modified file/); expect(fs.readFileSync(filePath, 'utf8')).toBe( @@ -537,7 +516,8 @@ describe('EditTool', () => { new_string: 'new', expected_replacements: 3, // Expecting 3 but only 2 exist }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch( /Expected 3 occurrences but found 2 for old_string in file/, ); @@ -553,7 +533,8 @@ describe('EditTool', () => { old_string: '', new_string: 'new content', }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch(/File already exists, cannot create/); expect(result.returnDisplay).toMatch( /Attempted to create a file that already exists/, @@ -573,7 +554,8 @@ describe('EditTool', () => { (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( ApprovalMode.AUTO_EDIT, ); - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch( /User modified the `new_string` content/, @@ -593,7 +575,8 @@ describe('EditTool', () => { (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( ApprovalMode.AUTO_EDIT, ); - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).not.toMatch( /User modified the `new_string` content/, @@ -612,7 +595,8 @@ describe('EditTool', () => { (mockConfig.getApprovalMode as Mock).mockReturnValueOnce( ApprovalMode.AUTO_EDIT, ); - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).not.toMatch( /User modified the `new_string` content/, @@ -627,7 +611,8 @@ describe('EditTool', () => { old_string: 'identical', new_string: 'identical', }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toMatch(/No changes to apply/); expect(result.returnDisplay).toMatch(/No changes to apply/); }); @@ -647,7 +632,8 @@ describe('EditTool', () => { old_string: 'any', new_string: 'new', }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe(ToolErrorType.FILE_NOT_FOUND); }); @@ -658,7 +644,8 @@ describe('EditTool', () => { old_string: '', new_string: 'new content', }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe( ToolErrorType.ATTEMPT_TO_CREATE_EXISTING_FILE, ); @@ -671,7 +658,8 @@ describe('EditTool', () => { old_string: 'not-found', new_string: 'new', }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_OCCURRENCE_FOUND); }); @@ -683,7 +671,8 @@ describe('EditTool', () => { new_string: 'new', expected_replacements: 3, }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe( ToolErrorType.EDIT_EXPECTED_OCCURRENCE_MISMATCH, ); @@ -696,18 +685,18 @@ describe('EditTool', () => { old_string: 'content', new_string: 'content', }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe(ToolErrorType.EDIT_NO_CHANGE); }); - it('should return INVALID_PARAMETERS error for relative path', async () => { + it('should throw INVALID_PARAMETERS error for relative path', async () => { const params: EditToolParams = { file_path: 'relative/path.txt', old_string: 'a', new_string: 'b', }; - const result = await tool.execute(params, new AbortController().signal); - expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS); + expect(() => tool.build(params)).toThrow(); }); it('should return FILE_WRITE_FAILURE on write error', async () => { @@ -720,7 +709,8 @@ describe('EditTool', () => { old_string: 'content', new_string: 'new content', }; - const result = await tool.execute(params, new AbortController().signal); + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe(ToolErrorType.FILE_WRITE_FAILURE); }); }); @@ -733,8 +723,9 @@ describe('EditTool', () => { old_string: 'identical_string', new_string: 'identical_string', }; + const invocation = tool.build(params); // shortenPath will be called internally, resulting in just the file name - expect(tool.getDescription(params)).toBe( + expect(invocation.getDescription()).toBe( `No file changes to ${testFileName}`, ); }); @@ -746,9 +737,10 @@ describe('EditTool', () => { old_string: 'this is the old string value', new_string: 'this is the new string value', }; + const invocation = tool.build(params); // shortenPath will be called internally, resulting in just the file name // The snippets are truncated at 30 chars + '...' - expect(tool.getDescription(params)).toBe( + expect(invocation.getDescription()).toBe( `${testFileName}: this is the old string value => this is the new string value`, ); }); @@ -760,7 +752,8 @@ describe('EditTool', () => { old_string: 'old', new_string: 'new', }; - expect(tool.getDescription(params)).toBe(`${testFileName}: old => new`); + const invocation = tool.build(params); + expect(invocation.getDescription()).toBe(`${testFileName}: old => new`); }); it('should truncate long strings in the description', () => { @@ -772,7 +765,8 @@ describe('EditTool', () => { new_string: 'this is a very long new string that will also be truncated', }; - expect(tool.getDescription(params)).toBe( + const invocation = tool.build(params); + expect(invocation.getDescription()).toBe( `${testFileName}: this is a very long old string... => this is a very long new string...`, ); }); @@ -839,8 +833,8 @@ describe('EditTool', () => { content: modifiedContent, }); - const confirmation = await tool.shouldConfirmExecute( - params, + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( new AbortController().signal, ); diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 43505182..f1d0498a 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -8,11 +8,12 @@ import * as fs from 'fs'; import * as path from 'path'; import * as Diff from 'diff'; import { - BaseTool, + BaseDeclarativeTool, Icon, ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolEditConfirmationDetails, + ToolInvocation, ToolLocation, ToolResult, ToolResultDisplay, @@ -29,6 +30,26 @@ import { ReadFileTool } from './read-file.js'; import { ModifiableDeclarativeTool, ModifyContext } from './modifiable-tool.js'; import { IDEConnectionStatus } from '../ide/ide-client.js'; +export function applyReplacement( + currentContent: string | null, + oldString: string, + newString: string, + isNewFile: boolean, +): string { + if (isNewFile) { + return newString; + } + if (currentContent === null) { + // Should not happen if not a new file, but defensively return empty or newString if oldString is also empty + return oldString === '' ? newString : ''; + } + // If oldString is empty and it's not a new file, do not modify the content. + if (oldString === '' && !isNewFile) { + return currentContent; + } + return currentContent.replaceAll(oldString, newString); +} + /** * Parameters for the Edit tool */ @@ -68,112 +89,14 @@ interface CalculatedEdit { isNewFile: boolean; } -/** - * Implementation of the Edit tool logic - */ -export class EditTool - extends BaseTool - implements ModifiableDeclarativeTool -{ - static readonly Name = 'replace'; +class EditToolInvocation implements ToolInvocation { + constructor( + private readonly config: Config, + public params: EditToolParams, + ) {} - constructor(private readonly config: Config) { - super( - EditTool.Name, - 'Edit', - `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement. - - The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. - -Expectation for required parameters: -1. \`file_path\` MUST be an absolute path; otherwise an error will be thrown. -2. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). -3. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic. -4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. -**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. -**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, - Icon.Pencil, - { - properties: { - file_path: { - description: - "The absolute path to the file to modify. Must start with '/'.", - type: Type.STRING, - }, - old_string: { - description: - 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', - type: Type.STRING, - }, - new_string: { - description: - 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', - type: Type.STRING, - }, - expected_replacements: { - type: Type.NUMBER, - description: - 'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', - minimum: 1, - }, - }, - required: ['file_path', 'old_string', 'new_string'], - type: Type.OBJECT, - }, - ); - } - - /** - * Validates the parameters for the Edit tool - * @param params Parameters to validate - * @returns Error message string or null if valid - */ - validateToolParams(params: EditToolParams): string | null { - const errors = SchemaValidator.validate(this.schema.parameters, params); - if (errors) { - return errors; - } - - if (!path.isAbsolute(params.file_path)) { - return `File path must be absolute: ${params.file_path}`; - } - - const workspaceContext = this.config.getWorkspaceContext(); - if (!workspaceContext.isPathWithinWorkspace(params.file_path)) { - const directories = workspaceContext.getDirectories(); - return `File path must be within one of the workspace directories: ${directories.join(', ')}`; - } - - return null; - } - - /** - * Determines any file locations affected by the tool execution - * @param params Parameters for the tool execution - * @returns A list of such paths - */ - toolLocations(params: EditToolParams): ToolLocation[] { - return [{ path: params.file_path }]; - } - - private _applyReplacement( - currentContent: string | null, - oldString: string, - newString: string, - isNewFile: boolean, - ): string { - if (isNewFile) { - return newString; - } - if (currentContent === null) { - // Should not happen if not a new file, but defensively return empty or newString if oldString is also empty - return oldString === '' ? newString : ''; - } - // If oldString is empty and it's not a new file, do not modify the content. - if (oldString === '' && !isNewFile) { - return currentContent; - } - return currentContent.replaceAll(oldString, newString); + toolLocations(): ToolLocation[] { + return [{ path: this.params.file_path }]; } /** @@ -271,7 +194,7 @@ Expectation for required parameters: }; } - const newContent = this._applyReplacement( + const newContent = applyReplacement( currentContent, finalOldString, finalNewString, @@ -292,23 +215,15 @@ Expectation for required parameters: * It needs to calculate the diff to show the user. */ async shouldConfirmExecute( - params: EditToolParams, abortSignal: AbortSignal, ): Promise { if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) { return false; } - const validationError = this.validateToolParams(params); - if (validationError) { - console.error( - `[EditTool Wrapper] Attempted confirmation with invalid parameters: ${validationError}`, - ); - return false; - } let editData: CalculatedEdit; try { - editData = await this.calculateEdit(params, abortSignal); + editData = await this.calculateEdit(this.params, abortSignal); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); console.log(`Error preparing edit: ${errorMsg}`); @@ -320,7 +235,7 @@ Expectation for required parameters: return false; } - const fileName = path.basename(params.file_path); + const fileName = path.basename(this.params.file_path); const fileDiff = Diff.createPatch( fileName, editData.currentContent ?? '', @@ -334,14 +249,14 @@ Expectation for required parameters: this.config.getIdeModeFeature() && this.config.getIdeMode() && ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected - ? ideClient.openDiff(params.file_path, editData.newContent) + ? ideClient.openDiff(this.params.file_path, editData.newContent) : undefined; const confirmationDetails: ToolEditConfirmationDetails = { type: 'edit', - title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`, + title: `Confirm Edit: ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`, fileName, - filePath: params.file_path, + filePath: this.params.file_path, fileDiff, originalContent: editData.currentContent, newContent: editData.newContent, @@ -355,8 +270,8 @@ Expectation for required parameters: if (result.status === 'accepted' && result.content) { // TODO(chrstn): See https://github.com/google-gemini/gemini-cli/pull/5618#discussion_r2255413084 // for info on a possible race condition where the file is modified on disk while being edited. - params.old_string = editData.currentContent ?? ''; - params.new_string = result.content; + this.params.old_string = editData.currentContent ?? ''; + this.params.new_string = result.content; } } }, @@ -365,26 +280,23 @@ Expectation for required parameters: return confirmationDetails; } - getDescription(params: EditToolParams): string { - if (!params.file_path || !params.old_string || !params.new_string) { - return `Model did not provide valid parameters for edit tool`; - } + getDescription(): string { const relativePath = makeRelative( - params.file_path, + this.params.file_path, this.config.getTargetDir(), ); - if (params.old_string === '') { + if (this.params.old_string === '') { return `Create ${shortenPath(relativePath)}`; } const oldStringSnippet = - params.old_string.split('\n')[0].substring(0, 30) + - (params.old_string.length > 30 ? '...' : ''); + this.params.old_string.split('\n')[0].substring(0, 30) + + (this.params.old_string.length > 30 ? '...' : ''); const newStringSnippet = - params.new_string.split('\n')[0].substring(0, 30) + - (params.new_string.length > 30 ? '...' : ''); + this.params.new_string.split('\n')[0].substring(0, 30) + + (this.params.new_string.length > 30 ? '...' : ''); - if (params.old_string === params.new_string) { + if (this.params.old_string === this.params.new_string) { return `No file changes to ${shortenPath(relativePath)}`; } return `${shortenPath(relativePath)}: ${oldStringSnippet} => ${newStringSnippet}`; @@ -395,25 +307,10 @@ Expectation for required parameters: * @param params Parameters for the edit operation * @returns Result of the edit operation */ - async execute( - params: EditToolParams, - signal: AbortSignal, - ): Promise { - const validationError = this.validateToolParams(params); - if (validationError) { - return { - llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, - returnDisplay: `Error: ${validationError}`, - error: { - message: validationError, - type: ToolErrorType.INVALID_TOOL_PARAMS, - }, - }; - } - + async execute(signal: AbortSignal): Promise { let editData: CalculatedEdit; try { - editData = await this.calculateEdit(params, signal); + editData = await this.calculateEdit(this.params, signal); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); return { @@ -438,16 +335,16 @@ Expectation for required parameters: } try { - this.ensureParentDirectoriesExist(params.file_path); - fs.writeFileSync(params.file_path, editData.newContent, 'utf8'); + this.ensureParentDirectoriesExist(this.params.file_path); + fs.writeFileSync(this.params.file_path, editData.newContent, 'utf8'); let displayResult: ToolResultDisplay; if (editData.isNewFile) { - displayResult = `Created ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`; + displayResult = `Created ${shortenPath(makeRelative(this.params.file_path, this.config.getTargetDir()))}`; } else { // Generate diff for display, even though core logic doesn't technically need it // The CLI wrapper will use this part of the ToolResult - const fileName = path.basename(params.file_path); + const fileName = path.basename(this.params.file_path); const fileDiff = Diff.createPatch( fileName, editData.currentContent ?? '', // Should not be null here if not isNewFile @@ -466,12 +363,12 @@ Expectation for required parameters: const llmSuccessMessageParts = [ editData.isNewFile - ? `Created new file: ${params.file_path} with provided content.` - : `Successfully modified file: ${params.file_path} (${editData.occurrences} replacements).`, + ? `Created new file: ${this.params.file_path} with provided content.` + : `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`, ]; - if (params.modified_by_user) { + if (this.params.modified_by_user) { llmSuccessMessageParts.push( - `User modified the \`new_string\` content to be: ${params.new_string}.`, + `User modified the \`new_string\` content to be: ${this.params.new_string}.`, ); } @@ -501,6 +398,91 @@ Expectation for required parameters: fs.mkdirSync(dirName, { recursive: true }); } } +} + +/** + * Implementation of the Edit tool logic + */ +export class EditTool + extends BaseDeclarativeTool + implements ModifiableDeclarativeTool +{ + static readonly Name = 'replace'; + constructor(private readonly config: Config) { + super( + EditTool.Name, + 'Edit', + `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${ReadFileTool.Name} tool to examine the file's current content before attempting a text replacement. + + The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. + +Expectation for required parameters: +1. \`file_path\` MUST be an absolute path; otherwise an error will be thrown. +2. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). +3. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic. +4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. +**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. +**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, + Icon.Pencil, + { + properties: { + file_path: { + description: + "The absolute path to the file to modify. Must start with '/'.", + type: Type.STRING, + }, + old_string: { + description: + 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. For multiple replacements, specify expected_replacements parameter. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', + type: Type.STRING, + }, + new_string: { + description: + 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', + type: Type.STRING, + }, + expected_replacements: { + type: Type.NUMBER, + description: + 'Number of replacements expected. Defaults to 1 if not specified. Use when you want to replace multiple occurrences.', + minimum: 1, + }, + }, + required: ['file_path', 'old_string', 'new_string'], + type: Type.OBJECT, + }, + ); + } + + /** + * Validates the parameters for the Edit tool + * @param params Parameters to validate + * @returns Error message string or null if valid + */ + validateToolParams(params: EditToolParams): string | null { + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; + } + + if (!path.isAbsolute(params.file_path)) { + return `File path must be absolute: ${params.file_path}`; + } + + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(params.file_path)) { + const directories = workspaceContext.getDirectories(); + return `File path must be within one of the workspace directories: ${directories.join(', ')}`; + } + + return null; + } + + protected createInvocation( + params: EditToolParams, + ): ToolInvocation { + return new EditToolInvocation(this.config, params); + } getModifyContext(_: AbortSignal): ModifyContext { return { @@ -516,7 +498,7 @@ Expectation for required parameters: getProposedContent: async (params: EditToolParams): Promise => { try { const currentContent = fs.readFileSync(params.file_path, 'utf8'); - return this._applyReplacement( + return applyReplacement( currentContent, params.old_string, params.new_string, diff --git a/packages/core/src/tools/glob.test.ts b/packages/core/src/tools/glob.test.ts index 0ee6c0ee..934b7ce7 100644 --- a/packages/core/src/tools/glob.test.ts +++ b/packages/core/src/tools/glob.test.ts @@ -64,7 +64,8 @@ describe('GlobTool', () => { describe('execute', () => { it('should find files matching a simple pattern in the root', async () => { const params: GlobToolParams = { pattern: '*.txt' }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 2 file(s)'); expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT')); @@ -73,7 +74,8 @@ describe('GlobTool', () => { it('should find files case-sensitively when case_sensitive is true', async () => { const params: GlobToolParams = { pattern: '*.txt', case_sensitive: true }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 1 file(s)'); expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); expect(result.llmContent).not.toContain( @@ -83,7 +85,8 @@ describe('GlobTool', () => { it('should find files case-insensitively by default (pattern: *.TXT)', async () => { const params: GlobToolParams = { pattern: '*.TXT' }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 2 file(s)'); expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT')); @@ -94,7 +97,8 @@ describe('GlobTool', () => { pattern: '*.TXT', case_sensitive: false, }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 2 file(s)'); expect(result.llmContent).toContain(path.join(tempRootDir, 'fileA.txt')); expect(result.llmContent).toContain(path.join(tempRootDir, 'FileB.TXT')); @@ -102,7 +106,8 @@ describe('GlobTool', () => { it('should find files using a pattern that includes a subdirectory', async () => { const params: GlobToolParams = { pattern: 'sub/*.md' }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 2 file(s)'); expect(result.llmContent).toContain( path.join(tempRootDir, 'sub', 'fileC.md'), @@ -114,7 +119,8 @@ describe('GlobTool', () => { it('should find files in a specified relative path (relative to rootDir)', async () => { const params: GlobToolParams = { pattern: '*.md', path: 'sub' }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 2 file(s)'); expect(result.llmContent).toContain( path.join(tempRootDir, 'sub', 'fileC.md'), @@ -126,7 +132,8 @@ describe('GlobTool', () => { it('should find files using a deep globstar pattern (e.g., **/*.log)', async () => { const params: GlobToolParams = { pattern: '**/*.log' }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 1 file(s)'); expect(result.llmContent).toContain( path.join(tempRootDir, 'sub', 'deep', 'fileE.log'), @@ -135,7 +142,8 @@ describe('GlobTool', () => { it('should return "No files found" message when pattern matches nothing', async () => { const params: GlobToolParams = { pattern: '*.nonexistent' }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( 'No files found matching pattern "*.nonexistent"', ); @@ -144,7 +152,8 @@ describe('GlobTool', () => { it('should correctly sort files by modification time (newest first)', async () => { const params: GlobToolParams = { pattern: '*.sortme' }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); const llmContent = partListUnionToString(result.llmContent); expect(llmContent).toContain('Found 2 file(s)'); @@ -242,8 +251,8 @@ describe('GlobTool', () => { // Let's try to go further up. const paramsOutside: GlobToolParams = { pattern: '*.txt', - path: '../../../../../../../../../../tmp', - }; // Definitely outside + path: '../../../../../../../../../../tmp', // Definitely outside + }; expect(specificGlobTool.validateToolParams(paramsOutside)).toContain( 'resolves outside the allowed workspace directories', ); @@ -290,7 +299,8 @@ describe('GlobTool', () => { it('should work with paths in workspace subdirectories', async () => { const params: GlobToolParams = { pattern: '*.md', path: 'sub' }; - const result = await globTool.execute(params, abortSignal); + const invocation = globTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain('Found 2 file(s)'); expect(result.llmContent).toContain('fileC.md'); diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 5bcb9778..df0cc348 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -8,7 +8,13 @@ import fs from 'fs'; import path from 'path'; import { glob } from 'glob'; import { SchemaValidator } from '../utils/schemaValidator.js'; -import { BaseTool, Icon, ToolResult } from './tools.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Icon, + ToolInvocation, + ToolResult, +} from './tools.js'; import { Type } from '@google/genai'; import { shortenPath, makeRelative } from '../utils/paths.js'; import { Config } from '../config/config.js'; @@ -74,10 +80,168 @@ export interface GlobToolParams { respect_git_ignore?: boolean; } +class GlobToolInvocation extends BaseToolInvocation< + GlobToolParams, + ToolResult +> { + constructor( + private config: Config, + params: GlobToolParams, + ) { + super(params); + } + + getDescription(): string { + let description = `'${this.params.pattern}'`; + if (this.params.path) { + const searchDir = path.resolve( + this.config.getTargetDir(), + this.params.path || '.', + ); + const relativePath = makeRelative(searchDir, this.config.getTargetDir()); + description += ` within ${shortenPath(relativePath)}`; + } + return description; + } + + async execute(signal: AbortSignal): Promise { + try { + const workspaceContext = this.config.getWorkspaceContext(); + const workspaceDirectories = workspaceContext.getDirectories(); + + // If a specific path is provided, resolve it and check if it's within workspace + let searchDirectories: readonly string[]; + if (this.params.path) { + const searchDirAbsolute = path.resolve( + this.config.getTargetDir(), + this.params.path, + ); + if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) { + return { + llmContent: `Error: Path "${this.params.path}" is not within any workspace directory`, + returnDisplay: `Path is not within workspace`, + }; + } + searchDirectories = [searchDirAbsolute]; + } else { + // Search across all workspace directories + searchDirectories = workspaceDirectories; + } + + // Get centralized file discovery service + const respectGitIgnore = + this.params.respect_git_ignore ?? + this.config.getFileFilteringRespectGitIgnore(); + const fileDiscovery = this.config.getFileService(); + + // Collect entries from all search directories + let allEntries: GlobPath[] = []; + + for (const searchDir of searchDirectories) { + const entries = (await glob(this.params.pattern, { + cwd: searchDir, + withFileTypes: true, + nodir: true, + stat: true, + nocase: !this.params.case_sensitive, + dot: true, + ignore: ['**/node_modules/**', '**/.git/**'], + follow: false, + signal, + })) as GlobPath[]; + + allEntries = allEntries.concat(entries); + } + + const entries = allEntries; + + // Apply git-aware filtering if enabled and in git repository + let filteredEntries = entries; + let gitIgnoredCount = 0; + + if (respectGitIgnore) { + const relativePaths = entries.map((p) => + path.relative(this.config.getTargetDir(), p.fullpath()), + ); + const filteredRelativePaths = fileDiscovery.filterFiles(relativePaths, { + respectGitIgnore, + }); + const filteredAbsolutePaths = new Set( + filteredRelativePaths.map((p) => + path.resolve(this.config.getTargetDir(), p), + ), + ); + + filteredEntries = entries.filter((entry) => + filteredAbsolutePaths.has(entry.fullpath()), + ); + gitIgnoredCount = entries.length - filteredEntries.length; + } + + if (!filteredEntries || filteredEntries.length === 0) { + let message = `No files found matching pattern "${this.params.pattern}"`; + if (searchDirectories.length === 1) { + message += ` within ${searchDirectories[0]}`; + } else { + message += ` within ${searchDirectories.length} workspace directories`; + } + if (gitIgnoredCount > 0) { + message += ` (${gitIgnoredCount} files were git-ignored)`; + } + return { + llmContent: message, + returnDisplay: `No files found`, + }; + } + + // Set filtering such that we first show the most recent files + const oneDayInMs = 24 * 60 * 60 * 1000; + const nowTimestamp = new Date().getTime(); + + // Sort the filtered entries using the new helper function + const sortedEntries = sortFileEntries( + filteredEntries, + nowTimestamp, + oneDayInMs, + ); + + const sortedAbsolutePaths = sortedEntries.map((entry) => + entry.fullpath(), + ); + const fileListDescription = sortedAbsolutePaths.join('\n'); + const fileCount = sortedAbsolutePaths.length; + + let resultMessage = `Found ${fileCount} file(s) matching "${this.params.pattern}"`; + if (searchDirectories.length === 1) { + resultMessage += ` within ${searchDirectories[0]}`; + } else { + resultMessage += ` across ${searchDirectories.length} workspace directories`; + } + if (gitIgnoredCount > 0) { + resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`; + } + resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`; + + return { + llmContent: resultMessage, + returnDisplay: `Found ${fileCount} matching file(s)`, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`GlobLogic execute Error: ${errorMessage}`, error); + return { + llmContent: `Error during glob search operation: ${errorMessage}`, + returnDisplay: `Error: An unexpected error occurred.`, + }; + } + } +} + /** * Implementation of the Glob tool logic */ -export class GlobTool extends BaseTool { +export class GlobTool extends BaseDeclarativeTool { static readonly Name = 'glob'; constructor(private config: Config) { @@ -158,166 +322,9 @@ export class GlobTool extends BaseTool { return null; } - /** - * Gets a description of the glob operation. - */ - getDescription(params: GlobToolParams): string { - let description = `'${params.pattern}'`; - if (params.path) { - const searchDir = path.resolve( - this.config.getTargetDir(), - params.path || '.', - ); - const relativePath = makeRelative(searchDir, this.config.getTargetDir()); - description += ` within ${shortenPath(relativePath)}`; - } - return description; - } - - /** - * Executes the glob search with the given parameters - */ - async execute( + protected createInvocation( params: GlobToolParams, - signal: AbortSignal, - ): Promise { - const validationError = this.validateToolParams(params); - if (validationError) { - return { - llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, - returnDisplay: validationError, - }; - } - - try { - const workspaceContext = this.config.getWorkspaceContext(); - const workspaceDirectories = workspaceContext.getDirectories(); - - // If a specific path is provided, resolve it and check if it's within workspace - let searchDirectories: readonly string[]; - if (params.path) { - const searchDirAbsolute = path.resolve( - this.config.getTargetDir(), - params.path, - ); - if (!workspaceContext.isPathWithinWorkspace(searchDirAbsolute)) { - return { - llmContent: `Error: Path "${params.path}" is not within any workspace directory`, - returnDisplay: `Path is not within workspace`, - }; - } - searchDirectories = [searchDirAbsolute]; - } else { - // Search across all workspace directories - searchDirectories = workspaceDirectories; - } - - // Get centralized file discovery service - const respectGitIgnore = - params.respect_git_ignore ?? - this.config.getFileFilteringRespectGitIgnore(); - const fileDiscovery = this.config.getFileService(); - - // Collect entries from all search directories - let allEntries: GlobPath[] = []; - - for (const searchDir of searchDirectories) { - const entries = (await glob(params.pattern, { - cwd: searchDir, - withFileTypes: true, - nodir: true, - stat: true, - nocase: !params.case_sensitive, - dot: true, - ignore: ['**/node_modules/**', '**/.git/**'], - follow: false, - signal, - })) as GlobPath[]; - - allEntries = allEntries.concat(entries); - } - - const entries = allEntries; - - // Apply git-aware filtering if enabled and in git repository - let filteredEntries = entries; - let gitIgnoredCount = 0; - - if (respectGitIgnore) { - const relativePaths = entries.map((p) => - path.relative(this.config.getTargetDir(), p.fullpath()), - ); - const filteredRelativePaths = fileDiscovery.filterFiles(relativePaths, { - respectGitIgnore, - }); - const filteredAbsolutePaths = new Set( - filteredRelativePaths.map((p) => - path.resolve(this.config.getTargetDir(), p), - ), - ); - - filteredEntries = entries.filter((entry) => - filteredAbsolutePaths.has(entry.fullpath()), - ); - gitIgnoredCount = entries.length - filteredEntries.length; - } - - if (!filteredEntries || filteredEntries.length === 0) { - let message = `No files found matching pattern "${params.pattern}"`; - if (searchDirectories.length === 1) { - message += ` within ${searchDirectories[0]}`; - } else { - message += ` within ${searchDirectories.length} workspace directories`; - } - if (gitIgnoredCount > 0) { - message += ` (${gitIgnoredCount} files were git-ignored)`; - } - return { - llmContent: message, - returnDisplay: `No files found`, - }; - } - - // Set filtering such that we first show the most recent files - const oneDayInMs = 24 * 60 * 60 * 1000; - const nowTimestamp = new Date().getTime(); - - // Sort the filtered entries using the new helper function - const sortedEntries = sortFileEntries( - filteredEntries, - nowTimestamp, - oneDayInMs, - ); - - const sortedAbsolutePaths = sortedEntries.map((entry) => - entry.fullpath(), - ); - const fileListDescription = sortedAbsolutePaths.join('\n'); - const fileCount = sortedAbsolutePaths.length; - - let resultMessage = `Found ${fileCount} file(s) matching "${params.pattern}"`; - if (searchDirectories.length === 1) { - resultMessage += ` within ${searchDirectories[0]}`; - } else { - resultMessage += ` across ${searchDirectories.length} workspace directories`; - } - if (gitIgnoredCount > 0) { - resultMessage += ` (${gitIgnoredCount} additional files were git-ignored)`; - } - resultMessage += `, sorted by modification time (newest first):\n${fileListDescription}`; - - return { - llmContent: resultMessage, - returnDisplay: `Found ${fileCount} matching file(s)`, - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - console.error(`GlobLogic execute Error: ${errorMessage}`, error); - return { - llmContent: `Error during glob search operation: ${errorMessage}`, - returnDisplay: `Error: An unexpected error occurred.`, - }; - } + ): ToolInvocation { + return new GlobToolInvocation(this.config, params); } } diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index aadc93ae..4bb59115 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -120,7 +120,8 @@ describe('GrepTool', () => { describe('execute', () => { it('should find matches for a simple pattern in all files', async () => { const params: GrepToolParams = { pattern: 'world' }; - const result = await grepTool.execute(params, abortSignal); + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( 'Found 3 matches for pattern "world" in the workspace directory', ); @@ -136,7 +137,8 @@ describe('GrepTool', () => { it('should find matches in a specific path', async () => { const params: GrepToolParams = { pattern: 'world', path: 'sub' }; - const result = await grepTool.execute(params, abortSignal); + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( 'Found 1 match for pattern "world" in path "sub"', ); @@ -147,7 +149,8 @@ describe('GrepTool', () => { it('should find matches with an include glob', async () => { const params: GrepToolParams = { pattern: 'hello', include: '*.js' }; - const result = await grepTool.execute(params, abortSignal); + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( 'Found 1 match for pattern "hello" in the workspace directory (filter: "*.js"):', ); @@ -168,7 +171,8 @@ describe('GrepTool', () => { path: 'sub', include: '*.js', }; - const result = await grepTool.execute(params, abortSignal); + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( 'Found 1 match for pattern "hello" in path "sub" (filter: "*.js")', ); @@ -179,7 +183,8 @@ describe('GrepTool', () => { it('should return "No matches found" when pattern does not exist', async () => { const params: GrepToolParams = { pattern: 'nonexistentpattern' }; - const result = await grepTool.execute(params, abortSignal); + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( 'No matches found for pattern "nonexistentpattern" in the workspace directory.', ); @@ -188,7 +193,8 @@ describe('GrepTool', () => { it('should handle regex special characters correctly', async () => { const params: GrepToolParams = { pattern: 'foo.*bar' }; // Matches 'const foo = "bar";' - const result = await grepTool.execute(params, abortSignal); + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( 'Found 1 match for pattern "foo.*bar" in the workspace directory:', ); @@ -198,7 +204,8 @@ describe('GrepTool', () => { it('should be case-insensitive by default (JS fallback)', async () => { const params: GrepToolParams = { pattern: 'HELLO' }; - const result = await grepTool.execute(params, abortSignal); + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( 'Found 2 matches for pattern "HELLO" in the workspace directory:', ); @@ -210,14 +217,10 @@ describe('GrepTool', () => { ); }); - it('should return an error if params are invalid', async () => { + it('should throw an error if params are invalid', async () => { const params = { path: '.' } as unknown as GrepToolParams; // Invalid: pattern missing - const result = await grepTool.execute(params, abortSignal); - expect(result.llmContent).toBe( - "Error: Invalid parameters provided. Reason: params must have required property 'pattern'", - ); - expect(result.returnDisplay).toBe( - "Model provided invalid parameters. Error: params must have required property 'pattern'", + expect(() => grepTool.build(params)).toThrow( + /params must have required property 'pattern'/, ); }); }); @@ -246,7 +249,8 @@ describe('GrepTool', () => { const multiDirGrepTool = new GrepTool(multiDirConfig); const params: GrepToolParams = { pattern: 'world' }; - const result = await multiDirGrepTool.execute(params, abortSignal); + const invocation = multiDirGrepTool.build(params); + const result = await invocation.execute(abortSignal); // Should find matches in both directories expect(result.llmContent).toContain( @@ -297,7 +301,8 @@ describe('GrepTool', () => { // Search only in the 'sub' directory of the first workspace const params: GrepToolParams = { pattern: 'world', path: 'sub' }; - const result = await multiDirGrepTool.execute(params, abortSignal); + const invocation = multiDirGrepTool.build(params); + const result = await invocation.execute(abortSignal); // Should only find matches in the specified sub directory expect(result.llmContent).toContain( @@ -317,7 +322,8 @@ describe('GrepTool', () => { describe('getDescription', () => { it('should generate correct description with pattern only', () => { const params: GrepToolParams = { pattern: 'testPattern' }; - expect(grepTool.getDescription(params)).toBe("'testPattern'"); + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toBe("'testPattern'"); }); it('should generate correct description with pattern and include', () => { @@ -325,19 +331,21 @@ describe('GrepTool', () => { pattern: 'testPattern', include: '*.ts', }; - expect(grepTool.getDescription(params)).toBe("'testPattern' in *.ts"); + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toBe("'testPattern' in *.ts"); }); - it('should generate correct description with pattern and path', () => { + it('should generate correct description with pattern and path', async () => { + const dirPath = path.join(tempRootDir, 'src', 'app'); + await fs.mkdir(dirPath, { recursive: true }); const params: GrepToolParams = { pattern: 'testPattern', path: path.join('src', 'app'), }; + const invocation = grepTool.build(params); // The path will be relative to the tempRootDir, so we check for containment. - expect(grepTool.getDescription(params)).toContain("'testPattern' within"); - expect(grepTool.getDescription(params)).toContain( - path.join('src', 'app'), - ); + expect(invocation.getDescription()).toContain("'testPattern' within"); + expect(invocation.getDescription()).toContain(path.join('src', 'app')); }); it('should indicate searching across all workspace directories when no path specified', () => { @@ -350,28 +358,31 @@ describe('GrepTool', () => { const multiDirGrepTool = new GrepTool(multiDirConfig); const params: GrepToolParams = { pattern: 'testPattern' }; - expect(multiDirGrepTool.getDescription(params)).toBe( + const invocation = multiDirGrepTool.build(params); + expect(invocation.getDescription()).toBe( "'testPattern' across all workspace directories", ); }); - it('should generate correct description with pattern, include, and path', () => { + it('should generate correct description with pattern, include, and path', async () => { + const dirPath = path.join(tempRootDir, 'src', 'app'); + await fs.mkdir(dirPath, { recursive: true }); const params: GrepToolParams = { pattern: 'testPattern', include: '*.ts', path: path.join('src', 'app'), }; - expect(grepTool.getDescription(params)).toContain( + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toContain( "'testPattern' in *.ts within", ); - expect(grepTool.getDescription(params)).toContain( - path.join('src', 'app'), - ); + expect(invocation.getDescription()).toContain(path.join('src', 'app')); }); it('should use ./ for root path in description', () => { const params: GrepToolParams = { pattern: 'testPattern', path: '.' }; - expect(grepTool.getDescription(params)).toBe("'testPattern' within ./"); + const invocation = grepTool.build(params); + expect(invocation.getDescription()).toBe("'testPattern' within ./"); }); }); }); diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 027ab1b1..8e2b84f1 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -10,7 +10,13 @@ import path from 'path'; import { EOL } from 'os'; import { spawn } from 'child_process'; import { globStream } from 'glob'; -import { BaseTool, Icon, ToolResult } from './tools.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Icon, + ToolInvocation, + ToolResult, +} from './tools.js'; import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; @@ -49,46 +55,17 @@ interface GrepMatch { line: string; } -// --- GrepLogic Class --- - -/** - * Implementation of the Grep tool logic (moved from CLI) - */ -export class GrepTool extends BaseTool { - static readonly Name = 'search_file_content'; // Keep static name - - constructor(private readonly config: Config) { - super( - GrepTool.Name, - 'SearchText', - 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.', - Icon.Regex, - { - properties: { - pattern: { - description: - "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", - type: Type.STRING, - }, - path: { - description: - 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', - type: Type.STRING, - }, - include: { - description: - "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", - type: Type.STRING, - }, - }, - required: ['pattern'], - type: Type.OBJECT, - }, - ); +class GrepToolInvocation extends BaseToolInvocation< + GrepToolParams, + ToolResult +> { + constructor( + private readonly config: Config, + params: GrepToolParams, + ) { + super(params); } - // --- Validation Methods --- - /** * Checks if a path is within the root directory and resolves it. * @param relativePath Path relative to the root directory (or undefined for root). @@ -130,58 +107,11 @@ export class GrepTool extends BaseTool { return targetPath; } - /** - * Validates the parameters for the tool - * @param params Parameters to validate - * @returns An error message string if invalid, null otherwise - */ - validateToolParams(params: GrepToolParams): string | null { - const errors = SchemaValidator.validate(this.schema.parameters, params); - if (errors) { - return errors; - } - - try { - new RegExp(params.pattern); - } catch (error) { - return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`; - } - - // Only validate path if one is provided - if (params.path) { - try { - this.resolveAndValidatePath(params.path); - } catch (error) { - return getErrorMessage(error); - } - } - - return null; // Parameters are valid - } - - // --- Core Execution --- - - /** - * Executes the grep search with the given parameters - * @param params Parameters for the grep search - * @returns Result of the grep search - */ - async execute( - params: GrepToolParams, - signal: AbortSignal, - ): Promise { - const validationError = this.validateToolParams(params); - if (validationError) { - return { - llmContent: `Error: Invalid parameters provided. Reason: ${validationError}`, - returnDisplay: `Model provided invalid parameters. Error: ${validationError}`, - }; - } - + async execute(signal: AbortSignal): Promise { try { const workspaceContext = this.config.getWorkspaceContext(); - const searchDirAbs = this.resolveAndValidatePath(params.path); - const searchDirDisplay = params.path || '.'; + const searchDirAbs = this.resolveAndValidatePath(this.params.path); + const searchDirDisplay = this.params.path || '.'; // Determine which directories to search let searchDirectories: readonly string[]; @@ -197,9 +127,9 @@ export class GrepTool extends BaseTool { let allMatches: GrepMatch[] = []; for (const searchDir of searchDirectories) { const matches = await this.performGrepSearch({ - pattern: params.pattern, + pattern: this.params.pattern, path: searchDir, - include: params.include, + include: this.params.include, signal, }); @@ -226,7 +156,7 @@ export class GrepTool extends BaseTool { } if (allMatches.length === 0) { - const noMatchMsg = `No matches found for pattern "${params.pattern}" ${searchLocationDescription}${params.include ? ` (filter: "${params.include}")` : ''}.`; + const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`; return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; } @@ -247,7 +177,7 @@ export class GrepTool extends BaseTool { const matchCount = allMatches.length; const matchTerm = matchCount === 1 ? 'match' : 'matches'; - let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${params.pattern}" ${searchLocationDescription}${params.include ? ` (filter: "${params.include}")` : ''}: + let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}: --- `; @@ -274,8 +204,6 @@ export class GrepTool extends BaseTool { } } - // --- Grep Implementation Logic --- - /** * Checks if a command is available in the system's PATH. * @param {string} command The command name (e.g., 'git', 'grep'). @@ -353,17 +281,20 @@ export class GrepTool extends BaseTool { * @param params Parameters for the grep operation * @returns A string describing the grep */ - getDescription(params: GrepToolParams): string { - let description = `'${params.pattern}'`; - if (params.include) { - description += ` in ${params.include}`; + getDescription(): string { + let description = `'${this.params.pattern}'`; + if (this.params.include) { + description += ` in ${this.params.include}`; } - if (params.path) { + if (this.params.path) { const resolvedPath = path.resolve( this.config.getTargetDir(), - params.path, + this.params.path, ); - if (resolvedPath === this.config.getTargetDir() || params.path === '.') { + if ( + resolvedPath === this.config.getTargetDir() || + this.params.path === '.' + ) { description += ` within ./`; } else { const relativePath = makeRelative( @@ -445,7 +376,9 @@ export class GrepTool extends BaseTool { return this.parseGrepOutput(output, absolutePath); } catch (gitError: unknown) { console.debug( - `GrepLogic: git grep failed: ${getErrorMessage(gitError)}. Falling back...`, + `GrepLogic: git grep failed: ${getErrorMessage( + gitError, + )}. Falling back...`, ); } } @@ -525,7 +458,9 @@ export class GrepTool extends BaseTool { return this.parseGrepOutput(output, absolutePath); } catch (grepError: unknown) { console.debug( - `GrepLogic: System grep failed: ${getErrorMessage(grepError)}. Falling back...`, + `GrepLogic: System grep failed: ${getErrorMessage( + grepError, + )}. Falling back...`, ); } } @@ -576,7 +511,9 @@ export class GrepTool extends BaseTool { // Ignore errors like permission denied or file gone during read if (!isNodeError(readError) || readError.code !== 'ENOENT') { console.debug( - `GrepLogic: Could not read/process ${fileAbsolutePath}: ${getErrorMessage(readError)}`, + `GrepLogic: Could not read/process ${fileAbsolutePath}: ${getErrorMessage( + readError, + )}`, ); } } @@ -585,9 +522,126 @@ export class GrepTool extends BaseTool { return allMatches; } catch (error: unknown) { console.error( - `GrepLogic: Error in performGrepSearch (Strategy: ${strategyUsed}): ${getErrorMessage(error)}`, + `GrepLogic: Error in performGrepSearch (Strategy: ${strategyUsed}): ${getErrorMessage( + error, + )}`, ); throw error; // Re-throw } } } + +// --- GrepLogic Class --- + +/** + * Implementation of the Grep tool logic (moved from CLI) + */ +export class GrepTool extends BaseDeclarativeTool { + static readonly Name = 'search_file_content'; // Keep static name + + constructor(private readonly config: Config) { + super( + GrepTool.Name, + 'SearchText', + 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.', + Icon.Regex, + { + properties: { + pattern: { + description: + "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').", + type: Type.STRING, + }, + path: { + description: + 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', + type: Type.STRING, + }, + include: { + description: + "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", + type: Type.STRING, + }, + }, + required: ['pattern'], + type: Type.OBJECT, + }, + ); + } + + /** + * Checks if a path is within the root directory and resolves it. + * @param relativePath Path relative to the root directory (or undefined for root). + * @returns The absolute path if valid and exists, or null if no path specified (to search all directories). + * @throws {Error} If path is outside root, doesn't exist, or isn't a directory. + */ + private resolveAndValidatePath(relativePath?: string): string | null { + // If no path specified, return null to indicate searching all workspace directories + if (!relativePath) { + return null; + } + + const targetPath = path.resolve(this.config.getTargetDir(), relativePath); + + // Security Check: Ensure the resolved path is within workspace boundaries + const workspaceContext = this.config.getWorkspaceContext(); + if (!workspaceContext.isPathWithinWorkspace(targetPath)) { + const directories = workspaceContext.getDirectories(); + throw new Error( + `Path validation failed: Attempted path "${relativePath}" resolves outside the allowed workspace directories: ${directories.join(', ')}`, + ); + } + + // Check existence and type after resolving + try { + const stats = fs.statSync(targetPath); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${targetPath}`); + } + } catch (error: unknown) { + if (isNodeError(error) && error.code !== 'ENOENT') { + throw new Error(`Path does not exist: ${targetPath}`); + } + throw new Error( + `Failed to access path stats for ${targetPath}: ${error}`, + ); + } + + return targetPath; + } + + /** + * Validates the parameters for the tool + * @param params Parameters to validate + * @returns An error message string if invalid, null otherwise + */ + validateToolParams(params: GrepToolParams): string | null { + const errors = SchemaValidator.validate(this.schema.parameters, params); + if (errors) { + return errors; + } + + try { + new RegExp(params.pattern); + } catch (error) { + return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`; + } + + // Only validate path if one is provided + if (params.path) { + try { + this.resolveAndValidatePath(params.path); + } catch (error) { + return getErrorMessage(error); + } + } + + return null; // Parameters are valid + } + + protected createInvocation( + params: GrepToolParams, + ): ToolInvocation { + return new GrepToolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index 7ef9d2b5..4c1d044c 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -9,6 +9,7 @@ import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { BaseDeclarativeTool, + BaseToolInvocation, Icon, ToolInvocation, ToolLocation, @@ -45,13 +46,16 @@ export interface ReadFileToolParams { limit?: number; } -class ReadFileToolInvocation - implements ToolInvocation -{ +class ReadFileToolInvocation extends BaseToolInvocation< + ReadFileToolParams, + ToolResult +> { constructor( private config: Config, - public params: ReadFileToolParams, - ) {} + params: ReadFileToolParams, + ) { + super(params); + } getDescription(): string { const relativePath = makeRelative( @@ -61,14 +65,10 @@ class ReadFileToolInvocation return shortenPath(relativePath); } - toolLocations(): ToolLocation[] { + override toolLocations(): ToolLocation[] { return [{ path: this.params.absolute_path, line: this.params.offset }]; } - shouldConfirmExecute(): Promise { - return Promise.resolve(false); - } - async execute(): Promise { const result = await processSingleFileContent( this.params.absolute_path, diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 79e6f010..ceacd6ca 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -53,6 +53,34 @@ export interface ToolInvocation< ): Promise; } +/** + * A convenience base class for ToolInvocation. + */ +export abstract class BaseToolInvocation< + TParams extends object, + TResult extends ToolResult, +> implements ToolInvocation +{ + constructor(readonly params: TParams) {} + + abstract getDescription(): string; + + toolLocations(): ToolLocation[] { + return []; + } + + shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + return Promise.resolve(false); + } + + abstract execute( + signal: AbortSignal, + updateOutput?: (output: string) => void, + ): Promise; +} + /** * A type alias for a tool invocation where the specific parameter and result types are not known. */ From 7596481a9d537715ce74309b2b3da85d192d059a Mon Sep 17 00:00:00 2001 From: Shehab <127568346+dizzydroid@users.noreply.github.com> Date: Thu, 7 Aug 2025 20:26:55 +0300 Subject: [PATCH 037/107] feat(cli): Allow Exiting Authentication Menu with CTRL+C (SIGINT) (#4482) Co-authored-by: Seth Troisi --- packages/cli/src/ui/App.tsx | 4 ++++ packages/cli/src/ui/components/AuthInProgress.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 2be681e5..d311facf 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -632,6 +632,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ) { setShowIDEContextDetail((prev) => !prev); } else if (key.ctrl && (input === 'c' || input === 'C')) { + if (isAuthenticating) { + // Let AuthInProgress component handle the input. + return; + } handleExit(ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef); } else if (key.ctrl && (input === 'd' || input === 'D')) { if (buffer.text.length > 0) { diff --git a/packages/cli/src/ui/components/AuthInProgress.tsx b/packages/cli/src/ui/components/AuthInProgress.tsx index 196097f2..f05efe1d 100644 --- a/packages/cli/src/ui/components/AuthInProgress.tsx +++ b/packages/cli/src/ui/components/AuthInProgress.tsx @@ -18,8 +18,8 @@ export function AuthInProgress({ }: AuthInProgressProps): React.JSX.Element { const [timedOut, setTimedOut] = useState(false); - useInput((_, key) => { - if (key.escape) { + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) { onTimeout(); } }); @@ -48,7 +48,8 @@ export function AuthInProgress({ ) : ( - Waiting for auth... (Press ESC to cancel) + Waiting for auth... (Press ESC or CTRL+C to + cancel) )} From a3351bc9854584d24eca24fe8bc5b867e1fe3b03 Mon Sep 17 00:00:00 2001 From: anthony bushong Date: Thu, 7 Aug 2025 10:58:44 -0700 Subject: [PATCH 038/107] fix(tests): add missing deps in cli to fix sandbox runs (#5742) --- package-lock.json | 2 ++ packages/cli/package.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/package-lock.json b/package-lock.json index 1e5e4211..e254cab8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11681,6 +11681,7 @@ "@google/gemini-cli-core": "file:../core", "@google/genai": "1.9.0", "@iarna/toml": "^2.2.5", + "@modelcontextprotocol/sdk": "^1.15.1", "@types/update-notifier": "^6.0.8", "command-exists": "^1.2.9", "diff": "^7.0.0", @@ -11702,6 +11703,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", + "undici": "^7.10.0", "update-notifier": "^7.3.1", "yargs": "^17.7.2", "zod": "^3.23.8" diff --git a/packages/cli/package.json b/packages/cli/package.json index ca64f6f7..59d1cbf2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,7 @@ "@google/gemini-cli-core": "file:../core", "@google/genai": "1.9.0", "@iarna/toml": "^2.2.5", + "@modelcontextprotocol/sdk": "^1.15.1", "@types/update-notifier": "^6.0.8", "command-exists": "^1.2.9", "diff": "^7.0.0", @@ -52,6 +53,7 @@ "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", + "undici": "^7.10.0", "update-notifier": "^7.3.1", "yargs": "^17.7.2", "zod": "^3.23.8" From 8e6a565adbec90d2216fbc2e06d10bf9f0c0c1bf Mon Sep 17 00:00:00 2001 From: Pyush Sinha Date: Thu, 7 Aug 2025 11:16:47 -0700 Subject: [PATCH 039/107] fix: re render context usage indicator (#5102) --- .../src/ui/components/ContextUsageDisplay.tsx | 25 +++ packages/cli/src/ui/components/Footer.tsx | 158 +++++++++--------- 2 files changed, 102 insertions(+), 81 deletions(-) create mode 100644 packages/cli/src/ui/components/ContextUsageDisplay.tsx diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx new file mode 100644 index 00000000..037be333 --- /dev/null +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Text } from 'ink'; +import { Colors } from '../colors.js'; +import { tokenLimit } from '@google/gemini-cli-core'; + +export const ContextUsageDisplay = ({ + promptTokenCount, + model, +}: { + promptTokenCount: number; + model: string; +}) => { + const percentage = promptTokenCount / tokenLimit(model); + + return ( + + ({((1 - percentage) * 100).toFixed(0)}% context left) + + ); +}; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index acc55870..14cda5f3 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; -import { shortenPath, tildeifyPath, tokenLimit } from '@google/gemini-cli-core'; +import { shortenPath, tildeifyPath } from '@google/gemini-cli-core'; import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import process from 'node:process'; import Gradient from 'ink-gradient'; import { MemoryUsageDisplay } from './MemoryUsageDisplay.js'; - +import { ContextUsageDisplay } from './ContextUsageDisplay.js'; import { DebugProfiler } from './DebugProfiler.js'; interface FooterProps { @@ -43,85 +43,81 @@ export const Footer: React.FC = ({ promptTokenCount, nightly, vimMode, -}) => { - const limit = tokenLimit(model); - const percentage = promptTokenCount / limit; - - return ( - - - {debugMode && } - {vimMode && [{vimMode}] } - {nightly ? ( - - - {shortenPath(tildeifyPath(targetDir), 70)} - {branchName && ({branchName}*)} - - - ) : ( - - {shortenPath(tildeifyPath(targetDir), 70)} - {branchName && ({branchName}*)} - - )} - {debugMode && ( - - {' ' + (debugMessage || '--debug')} - - )} - - - {/* Middle Section: Centered Sandbox Info */} - - {process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? ( - - {process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')} - - ) : process.env.SANDBOX === 'sandbox-exec' ? ( - - macOS Seatbelt{' '} - ({process.env.SEATBELT_PROFILE}) - - ) : ( - - no sandbox (see /docs) - - )} - - - {/* Right Section: Gemini Label and Console Summary */} - - - {' '} - {model}{' '} - - ({((1 - percentage) * 100).toFixed(0)}% context left) - - - {corgiMode && ( +}) => ( + + + {debugMode && } + {vimMode && [{vimMode}] } + {nightly ? ( + - | - - - - `) - + {shortenPath(tildeifyPath(targetDir), 70)} + {branchName && ({branchName}*)} - )} - {!showErrorDetails && errorCount > 0 && ( - - | - - - )} - {showMemoryUsage && } - + + ) : ( + + {shortenPath(tildeifyPath(targetDir), 70)} + {branchName && ({branchName}*)} + + )} + {debugMode && ( + + {' ' + (debugMessage || '--debug')} + + )} - ); -}; + + {/* Middle Section: Centered Sandbox Info */} + + {process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec' ? ( + + {process.env.SANDBOX.replace(/^gemini-(?:cli-)?/, '')} + + ) : process.env.SANDBOX === 'sandbox-exec' ? ( + + macOS Seatbelt{' '} + ({process.env.SEATBELT_PROFILE}) + + ) : ( + + no sandbox (see /docs) + + )} + + + {/* Right Section: Gemini Label and Console Summary */} + + + {' '} + {model}{' '} + + + {corgiMode && ( + + | + + + + `) + + + )} + {!showErrorDetails && errorCount > 0 && ( + + | + + + )} + {showMemoryUsage && } + + +); From 3a3b1381950fb3aab09f71e0ad1662a4f77b3c43 Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Thu, 7 Aug 2025 20:21:39 +0000 Subject: [PATCH 040/107] Include Schema Error Handling for Vertex and Google Auth methods (#5780) Co-authored-by: Jacob Richman --- packages/core/src/core/geminiChat.ts | 61 +++++++++++++++------------- packages/core/src/core/turn.test.ts | 6 ++- packages/core/src/core/turn.ts | 1 + 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index cff23d2d..085bc993 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -33,7 +33,7 @@ import { } from '../telemetry/types.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js'; import { hasCycleInSchema } from '../tools/tools.js'; -import { isStructuredError } from '../utils/quotaErrorDetection.js'; +import { StructuredError } from './turn.js'; /** * Returns true if the response is valid, false otherwise. @@ -352,7 +352,6 @@ export class GeminiChat { } catch (error) { const durationMs = Date.now() - startTime; this._logApiError(durationMs, error, prompt_id); - await this.maybeIncludeSchemaDepthContext(error); this.sendPromise = Promise.resolve(); throw error; } @@ -452,7 +451,6 @@ export class GeminiChat { const durationMs = Date.now() - startTime; this._logApiError(durationMs, error, prompt_id); this.sendPromise = Promise.resolve(); - await this.maybeIncludeSchemaDepthContext(error); throw error; } } @@ -523,6 +521,34 @@ export class GeminiChat { return lastChunkWithMetadata?.usageMetadata; } + async maybeIncludeSchemaDepthContext(error: StructuredError): Promise { + // Check for potentially problematic cyclic tools with cyclic schemas + // and include a recommendation to remove potentially problematic tools. + if ( + isSchemaDepthError(error.message) || + isInvalidArgumentError(error.message) + ) { + const tools = (await this.config.getToolRegistry()).getAllTools(); + const cyclicSchemaTools: string[] = []; + for (const tool of tools) { + if ( + (tool.schema.parametersJsonSchema && + hasCycleInSchema(tool.schema.parametersJsonSchema)) || + (tool.schema.parameters && hasCycleInSchema(tool.schema.parameters)) + ) { + cyclicSchemaTools.push(tool.displayName); + } + } + if (cyclicSchemaTools.length > 0) { + const extraDetails = + `\n\nThis error was probably caused by cyclic schema references in one of the following tools, try disabling them with excludeTools:\n\n - ` + + cyclicSchemaTools.join(`\n - `) + + `\n`; + error.message += extraDetails; + } + } + } + private async *processStreamResponse( streamResponse: AsyncGenerator, inputContent: Content, @@ -684,34 +710,13 @@ export class GeminiChat { content.parts[0].thought === true ); } - - private async maybeIncludeSchemaDepthContext(error: unknown): Promise { - // Check for potentially problematic cyclic tools with cyclic schemas - // and include a recommendation to remove potentially problematic tools. - if (isStructuredError(error) && isSchemaDepthError(error.message)) { - const tools = (await this.config.getToolRegistry()).getAllTools(); - const cyclicSchemaTools: string[] = []; - for (const tool of tools) { - if ( - (tool.schema.parametersJsonSchema && - hasCycleInSchema(tool.schema.parametersJsonSchema)) || - (tool.schema.parameters && hasCycleInSchema(tool.schema.parameters)) - ) { - cyclicSchemaTools.push(tool.displayName); - } - } - if (cyclicSchemaTools.length > 0) { - const extraDetails = - `\n\nThis error was probably caused by cyclic schema references in one of the following tools, try disabling them:\n\n - ` + - cyclicSchemaTools.join(`\n - `) + - `\n`; - error.message += extraDetails; - } - } - } } /** Visible for Testing */ export function isSchemaDepthError(errorMessage: string): boolean { return errorMessage.includes('maximum schema depth exceeded'); } + +export function isInvalidArgumentError(errorMessage: string): boolean { + return errorMessage.includes('Request contains an invalid argument'); +} diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 2a557927..7144d16b 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -17,12 +17,14 @@ import { GeminiChat } from './geminiChat.js'; const mockSendMessageStream = vi.fn(); const mockGetHistory = vi.fn(); +const mockMaybeIncludeSchemaDepthContext = vi.fn(); vi.mock('@google/genai', async (importOriginal) => { const actual = await importOriginal(); const MockChat = vi.fn().mockImplementation(() => ({ sendMessageStream: mockSendMessageStream, getHistory: mockGetHistory, + maybeIncludeSchemaDepthContext: mockMaybeIncludeSchemaDepthContext, })); return { ...actual, @@ -46,6 +48,7 @@ describe('Turn', () => { type MockedChatInstance = { sendMessageStream: typeof mockSendMessageStream; getHistory: typeof mockGetHistory; + maybeIncludeSchemaDepthContext: typeof mockMaybeIncludeSchemaDepthContext; }; let mockChatInstance: MockedChatInstance; @@ -54,6 +57,7 @@ describe('Turn', () => { mockChatInstance = { sendMessageStream: mockSendMessageStream, getHistory: mockGetHistory, + maybeIncludeSchemaDepthContext: mockMaybeIncludeSchemaDepthContext, }; turn = new Turn(mockChatInstance as unknown as GeminiChat, 'prompt-id-1'); mockGetHistory.mockReturnValue([]); @@ -200,7 +204,7 @@ describe('Turn', () => { { role: 'model', parts: [{ text: 'Previous history' }] }, ]; mockGetHistory.mockReturnValue(historyContent); - + mockMaybeIncludeSchemaDepthContext.mockResolvedValue(undefined); const events = []; for await (const event of turn.run( reqParts, diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index ee32c309..8ede1fa4 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -275,6 +275,7 @@ export class Turn { message: getErrorMessage(error), status, }; + await this.chat.maybeIncludeSchemaDepthContext(structuredError); yield { type: GeminiEventType.Error, value: { error: structuredError } }; return; } From 53f8617b249c9f0443f5082a293a30504a118030 Mon Sep 17 00:00:00 2001 From: shrutip90 Date: Thu, 7 Aug 2025 14:06:17 -0700 Subject: [PATCH 041/107] Add new folderTrust setting that the users can enable or disable (#5798) --- packages/cli/src/config/config.test.ts | 52 ++++++++++++++++++++++ packages/cli/src/config/config.ts | 3 ++ packages/cli/src/config/settings.test.ts | 56 ++++++++++++++++++++++++ packages/cli/src/config/settings.ts | 12 ++++- packages/core/src/config/config.ts | 11 ++++- 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index b670fbc8..1d83ccbc 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1042,6 +1042,58 @@ describe('loadCliConfig folderTrustFeature', () => { }); }); +describe('loadCliConfig folderTrust', () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should be false if folderTrustFeature is false and folderTrust is false', async () => { + process.argv = ['node', 'script.js']; + const settings: Settings = { + folderTrustFeature: false, + folderTrust: false, + }; + const argv = await parseArguments(); + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getFolderTrust()).toBe(false); + }); + + it('should be false if folderTrustFeature is true and folderTrust is false', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { folderTrustFeature: true, folderTrust: false }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getFolderTrust()).toBe(false); + }); + + it('should be false if folderTrustFeature is false and folderTrust is true', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { folderTrustFeature: false, folderTrust: true }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getFolderTrust()).toBe(false); + }); + + it('should be true when folderTrustFeature is true and folderTrust is true', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { folderTrustFeature: true, folderTrust: true }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getFolderTrust()).toBe(true); + }); +}); + vi.mock('fs', async () => { const actualFs = await vi.importActual('fs'); const MOCK_CWD1 = process.cwd(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a47d8301..3104e4c1 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -314,6 +314,8 @@ export async function loadCliConfig( argv.ideModeFeature ?? settings.ideModeFeature ?? false; const folderTrustFeature = settings.folderTrustFeature ?? false; + const folderTrustSetting = settings.folderTrust ?? false; + const folderTrust = folderTrustFeature && folderTrustSetting; const allExtensions = annotateActiveExtensions( extensions, @@ -484,6 +486,7 @@ export async function loadCliConfig( ideModeFeature, chatCompression: settings.chatCompression, folderTrustFeature, + folderTrust, }); } diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index f68b13e3..353a5783 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -310,6 +310,62 @@ describe('Settings Loading and Merging', () => { }); }); + it('should ignore folderTrust from workspace settings', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + folderTrust: true, + }; + const workspaceSettingsContent = { + folderTrust: false, // This should be ignored + }; + const systemSettingsContent = { + // No folderTrust here + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) + return JSON.stringify(systemSettingsContent); + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.folderTrust).toBe(true); // User setting should be used + }); + + it('should use system folderTrust over user setting', () => { + (mockFsExistsSync as Mock).mockReturnValue(true); + const userSettingsContent = { + folderTrust: false, + }; + const workspaceSettingsContent = { + folderTrust: true, // This should be ignored + }; + const systemSettingsContent = { + folderTrust: true, + }; + + (fs.readFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === getSystemSettingsPath()) + return JSON.stringify(systemSettingsContent); + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.merged.folderTrust).toBe(true); // System setting should be used + }); + it('should handle contextFileName correctly when only in user settings', () => { (mockFsExistsSync as Mock).mockImplementation( (p: fs.PathLike) => p === USER_SETTINGS_PATH, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 8005ad65..a3bd8d47 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -113,10 +113,14 @@ export interface Settings { // Flag to be removed post-launch. ideModeFeature?: boolean; - folderTrustFeature?: boolean; /// IDE mode setting configured via slash command toggle. ideMode?: boolean; + // Flag to be removed post-launch. + folderTrustFeature?: boolean; + // Setting to track whether Folder trust is enabled. + folderTrust?: boolean; + // Setting to track if the user has seen the IDE integration nudge. hasSeenIdeIntegrationNudge?: boolean; @@ -178,9 +182,13 @@ export class LoadedSettings { const user = this.user.settings; const workspace = this.workspace.settings; + // folderTrust is not supported at workspace level. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { folderTrust, ...workspaceWithoutFolderTrust } = workspace; + return { ...user, - ...workspace, + ...workspaceWithoutFolderTrust, ...system, customThemes: { ...(user.customThemes || {}), diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 4848bfb6..db226c76 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -193,6 +193,7 @@ export interface ConfigParameters { summarizeToolOutput?: Record; ideModeFeature?: boolean; folderTrustFeature?: boolean; + folderTrust?: boolean; ideMode?: boolean; loadMemoryFromIncludeDirectories?: boolean; chatCompression?: ChatCompressionSettings; @@ -240,6 +241,7 @@ export class Config { private readonly noBrowser: boolean; private readonly ideModeFeature: boolean; private readonly folderTrustFeature: boolean; + private readonly folderTrust: boolean; private ideMode: boolean; private ideClient: IdeClient; private inFallbackMode = false; @@ -314,6 +316,7 @@ export class Config { this.summarizeToolOutput = params.summarizeToolOutput; this.ideModeFeature = params.ideModeFeature ?? false; this.folderTrustFeature = params.folderTrustFeature ?? false; + this.folderTrust = params.folderTrust ?? false; this.ideMode = params.ideMode ?? false; this.ideClient = IdeClient.getInstance(); if (this.ideMode && this.ideModeFeature) { @@ -648,12 +651,16 @@ export class Config { return this.ideModeFeature; } + getIdeMode(): boolean { + return this.ideMode; + } + getFolderTrustFeature(): boolean { return this.folderTrustFeature; } - getIdeMode(): boolean { - return this.ideMode; + getFolderTrust(): boolean { + return this.folderTrust; } setIdeMode(value: boolean): void { From 19491b7b940912c2fb3fe24b2f189d3fd5668669 Mon Sep 17 00:00:00 2001 From: Jacob MacDonald Date: Thu, 7 Aug 2025 14:19:06 -0700 Subject: [PATCH 042/107] avoid loading and initializing CLI config twice in non-interactive mode (#5793) --- packages/cli/src/config/config.test.ts | 142 +++++++++++++++++++++++- packages/cli/src/config/config.ts | 31 +++++- packages/cli/src/gemini.tsx | 61 +--------- packages/cli/src/nonInteractiveCli.ts | 1 - packages/core/src/config/config.test.ts | 12 ++ packages/core/src/config/config.ts | 15 +++ 6 files changed, 201 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 1d83ccbc..701ae267 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; +import { ShellTool, EditTool, WriteFileTool } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments } from './config.js'; import { Settings } from './settings.js'; import { Extension } from './extension.js'; @@ -561,6 +562,17 @@ describe('mergeMcpServers', () => { }); describe('mergeExcludeTools', () => { + const defaultExcludes = [ShellTool.Name, EditTool.Name, WriteFileTool.Name]; + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + process.stdin.isTTY = true; + }); + + afterEach(() => { + process.stdin.isTTY = originalIsTTY; + }); + it('should merge excludeTools from settings and extensions', async () => { const settings: Settings = { excludeTools: ['tool1', 'tool2'] }; const extensions: Extension[] = [ @@ -655,7 +667,8 @@ describe('mergeExcludeTools', () => { expect(config.getExcludeTools()).toHaveLength(4); }); - it('should return an empty array when no excludeTools are specified', async () => { + it('should return an empty array when no excludeTools are specified and it is interactive', async () => { + process.stdin.isTTY = true; const settings: Settings = {}; const extensions: Extension[] = []; process.argv = ['node', 'script.js']; @@ -669,6 +682,21 @@ describe('mergeExcludeTools', () => { expect(config.getExcludeTools()).toEqual([]); }); + it('should return default excludes when no excludeTools are specified and it is not interactive', async () => { + process.stdin.isTTY = false; + const settings: Settings = {}; + const extensions: Extension[] = []; + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments(); + const config = await loadCliConfig( + settings, + extensions, + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toEqual(defaultExcludes); + }); + it('should handle settings with excludeTools but no extensions', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); @@ -1214,3 +1242,115 @@ describe('loadCliConfig chatCompression', () => { expect(config.getChatCompression()).toBeUndefined(); }); }); + +describe('loadCliConfig tool exclusions', () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + process.stdin.isTTY = true; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + process.stdin.isTTY = originalIsTTY; + vi.restoreAllMocks(); + }); + + it('should not exclude interactive tools in interactive mode without YOLO', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); + }); + + it('should not exclude interactive tools in interactive mode with YOLO', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--yolo']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); + }); + + it('should exclude interactive tools in non-interactive mode without YOLO', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '-p', 'test']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getExcludeTools()).toContain('run_shell_command'); + expect(config.getExcludeTools()).toContain('replace'); + expect(config.getExcludeTools()).toContain('write_file'); + }); + + it('should not exclude interactive tools in non-interactive mode with YOLO', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '-p', 'test', '--yolo']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.getExcludeTools()).not.toContain('run_shell_command'); + expect(config.getExcludeTools()).not.toContain('replace'); + expect(config.getExcludeTools()).not.toContain('write_file'); + }); +}); + +describe('loadCliConfig interactive', () => { + const originalArgv = process.argv; + const originalEnv = { ...process.env }; + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + process.env.GEMINI_API_KEY = 'test-api-key'; + process.stdin.isTTY = true; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + process.stdin.isTTY = originalIsTTY; + vi.restoreAllMocks(); + }); + + it('should be interactive if isTTY and no prompt', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.isInteractive()).toBe(true); + }); + + it('should be interactive if prompt-interactive is set', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js', '--prompt-interactive', 'test']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.isInteractive()).toBe(true); + }); + + it('should not be interactive if not isTTY and no prompt', async () => { + process.stdin.isTTY = false; + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.isInteractive()).toBe(false); + }); + + it('should not be interactive if prompt is set', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--prompt', 'test']; + const argv = await parseArguments(); + const config = await loadCliConfig({}, [], 'test-session', argv); + expect(config.isInteractive()).toBe(false); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3104e4c1..d142bd12 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -23,6 +23,9 @@ import { FileDiscoveryService, TelemetryTarget, FileFilteringOptions, + ShellTool, + EditTool, + WriteFileTool, } from '@google/gemini-cli-core'; import { Settings } from './settings.js'; @@ -365,7 +368,22 @@ export async function loadCliConfig( ); let mcpServers = mergeMcpServers(settings, activeExtensions); - const excludeTools = mergeExcludeTools(settings, activeExtensions); + const question = argv.promptInteractive || argv.prompt || ''; + const approvalMode = + argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT; + const interactive = + !!argv.promptInteractive || (process.stdin.isTTY && question.length === 0); + // In non-interactive and non-yolo mode, exclude interactive built in tools. + const extraExcludes = + !interactive && approvalMode !== ApprovalMode.YOLO + ? [ShellTool.Name, EditTool.Name, WriteFileTool.Name] + : undefined; + + const excludeTools = mergeExcludeTools( + settings, + activeExtensions, + extraExcludes, + ); const blockedMcpServers: Array<{ name: string; extensionName: string }> = []; if (!argv.allowedMcpServerNames) { @@ -427,7 +445,7 @@ export async function loadCliConfig( settings.loadMemoryFromIncludeDirectories || false, debugMode, - question: argv.promptInteractive || argv.prompt || '', + question, fullContext: argv.allFiles || argv.all_files || false, coreTools: settings.coreTools || undefined, excludeTools, @@ -437,7 +455,7 @@ export async function loadCliConfig( mcpServers, userMemory: memoryContent, geminiMdFileCount: fileCount, - approvalMode: argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT, + approvalMode, showMemoryUsage: argv.showMemoryUsage || argv.show_memory_usage || @@ -486,6 +504,7 @@ export async function loadCliConfig( ideModeFeature, chatCompression: settings.chatCompression, folderTrustFeature, + interactive, folderTrust, }); } @@ -514,8 +533,12 @@ function mergeMcpServers(settings: Settings, extensions: Extension[]) { function mergeExcludeTools( settings: Settings, extensions: Extension[], + extraExcludes?: string[] | undefined, ): string[] { - const allExcludeTools = new Set(settings.excludeTools || []); + const allExcludeTools = new Set([ + ...(settings.excludeTools || []), + ...(extraExcludes || []), + ]); for (const extension of extensions) { for (const tool of extension.config.excludeTools || []) { allExcludeTools.add(tool); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 48dbd271..771fcacb 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { render } from 'ink'; import { AppWrapper } from './ui/App.js'; -import { loadCliConfig, parseArguments, CliArgs } from './config/config.js'; +import { loadCliConfig, parseArguments } from './config/config.js'; import { readStdin } from './utils/readStdin.js'; import { basename } from 'node:path'; import v8 from 'node:v8'; @@ -25,15 +25,11 @@ import { themeManager } from './ui/themes/theme-manager.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { runNonInteractive } from './nonInteractiveCli.js'; -import { loadExtensions, Extension } from './config/extension.js'; +import { loadExtensions } from './config/extension.js'; import { cleanupCheckpoints, registerCleanup } from './utils/cleanup.js'; import { getCliVersion } from './utils/version.js'; import { - ApprovalMode, Config, - EditTool, - ShellTool, - WriteFileTool, sessionId, logUserPrompt, AuthType, @@ -255,11 +251,8 @@ export async function main() { ...(await getUserStartupWarnings(workspaceRoot)), ]; - const shouldBeInteractive = - !!argv.promptInteractive || (process.stdin.isTTY && input?.length === 0); - // Render UI, passing necessary config values. Check that there is no command line question. - if (shouldBeInteractive) { + if (config.isInteractive()) { const version = await getCliVersion(); setWindowTitle(basename(workspaceRoot), settings); const instance = render( @@ -308,12 +301,10 @@ export async function main() { prompt_length: input.length, }); - // Non-interactive mode handled by runNonInteractive - const nonInteractiveConfig = await loadNonInteractiveConfig( + const nonInteractiveConfig = await validateNonInteractiveAuth( + settings.merged.selectedAuthType, + settings.merged.useExternalAuth, config, - extensions, - settings, - argv, ); await runNonInteractive(nonInteractiveConfig, input, prompt_id); @@ -334,43 +325,3 @@ function setWindowTitle(title: string, settings: LoadedSettings) { }); } } - -async function loadNonInteractiveConfig( - config: Config, - extensions: Extension[], - settings: LoadedSettings, - argv: CliArgs, -) { - let finalConfig = config; - if (config.getApprovalMode() !== ApprovalMode.YOLO) { - // Everything is not allowed, ensure that only read-only tools are configured. - const existingExcludeTools = settings.merged.excludeTools || []; - const interactiveTools = [ - ShellTool.Name, - EditTool.Name, - WriteFileTool.Name, - ]; - - const newExcludeTools = [ - ...new Set([...existingExcludeTools, ...interactiveTools]), - ]; - - const nonInteractiveSettings = { - ...settings.merged, - excludeTools: newExcludeTools, - }; - finalConfig = await loadCliConfig( - nonInteractiveSettings, - extensions, - config.getSessionId(), - argv, - ); - await finalConfig.initialize(); - } - - return await validateNonInteractiveAuth( - settings.merged.selectedAuthType, - settings.merged.useExternalAuth, - finalConfig, - ); -} diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 8b056a28..95ed70cf 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -30,7 +30,6 @@ export async function runNonInteractive( }); try { - await config.initialize(); consolePatcher.patch(); // Handle EPIPE errors when the output is piped to a command that closes early. process.stdout.on('error', (err: NodeJS.ErrnoException) => { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 64692139..8e6ca38f 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -150,6 +150,18 @@ describe('Server Config (config.ts)', () => { await expect(config.initialize()).resolves.toBeUndefined(); }); + + it('should throw an error if initialized more than once', async () => { + const config = new Config({ + ...baseParams, + checkpointing: false, + }); + + await expect(config.initialize()).resolves.toBeUndefined(); + await expect(config.initialize()).rejects.toThrow( + 'Config was already initialized', + ); + }); }); describe('refreshAuth', () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index db226c76..473ab5a6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -197,6 +197,7 @@ export interface ConfigParameters { ideMode?: boolean; loadMemoryFromIncludeDirectories?: boolean; chatCompression?: ChatCompressionSettings; + interactive?: boolean; } export class Config { @@ -260,6 +261,8 @@ export class Config { private readonly experimentalAcp: boolean = false; private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly chatCompression: ChatCompressionSettings | undefined; + private readonly interactive: boolean; + private initialized: boolean = false; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -326,6 +329,7 @@ export class Config { this.loadMemoryFromIncludeDirectories = params.loadMemoryFromIncludeDirectories ?? false; this.chatCompression = params.chatCompression; + this.interactive = params.interactive ?? false; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); @@ -344,7 +348,14 @@ export class Config { } } + /** + * Must only be called once, throws if called again. + */ async initialize(): Promise { + if (this.initialized) { + throw Error('Config was already initialized'); + } + this.initialized = true; // Initialize centralized FileDiscoveryService this.getFileService(); if (this.getCheckpointingEnabled()) { @@ -685,6 +696,10 @@ export class Config { return this.chatCompression; } + isInteractive(): boolean { + return this.interactive; + } + async getGitService(): Promise { if (!this.gitService) { this.gitService = new GitService(this.targetDir); From 4d4eacfc40f87ecc991aaecc12c046d49654425c Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Thu, 7 Aug 2025 17:19:31 -0400 Subject: [PATCH 043/107] Few IDE integration polishes (#5727) --- .../IDEContextDetailDisplay.test.tsx | 66 +++++++++++++++++++ .../ui/components/IDEContextDetailDisplay.tsx | 27 ++++++-- .../IDEContextDetailDisplay.test.tsx.snap | 24 +++++++ packages/core/src/ide/ide-client.ts | 43 ++++++++++-- 4 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/ui/components/IDEContextDetailDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/IDEContextDetailDisplay.test.tsx.snap diff --git a/packages/cli/src/ui/components/IDEContextDetailDisplay.test.tsx b/packages/cli/src/ui/components/IDEContextDetailDisplay.test.tsx new file mode 100644 index 00000000..629d6c2e --- /dev/null +++ b/packages/cli/src/ui/components/IDEContextDetailDisplay.test.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect } from 'vitest'; +import { IDEContextDetailDisplay } from './IDEContextDetailDisplay.js'; +import { type IdeContext } from '@google/gemini-cli-core'; + +describe('IDEContextDetailDisplay', () => { + it('renders an empty string when there are no open files', () => { + const ideContext: IdeContext = { + workspaceState: { + openFiles: [], + }, + }; + const { lastFrame } = render( + , + ); + expect(lastFrame()).toBe(''); + }); + + it('renders a list of open files with active status', () => { + const ideContext: IdeContext = { + workspaceState: { + openFiles: [ + { path: '/foo/bar.txt', isActive: true }, + { path: '/foo/baz.txt', isActive: false }, + ], + }, + }; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toMatchSnapshot(); + }); + + it('handles duplicate basenames by showing path hints', () => { + const ideContext: IdeContext = { + workspaceState: { + openFiles: [ + { path: '/foo/bar.txt', isActive: true }, + { path: '/qux/bar.txt', isActive: false }, + { path: '/foo/unique.txt', isActive: false }, + ], + }, + }; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx index a1739227..ec3c2dad 100644 --- a/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx +++ b/packages/cli/src/ui/components/IDEContextDetailDisplay.tsx @@ -23,6 +23,12 @@ export function IDEContextDetailDisplay({ return null; } + const basenameCounts = new Map(); + for (const file of openFiles) { + const basename = path.basename(file.path); + basenameCounts.set(basename, (basenameCounts.get(basename) || 0) + 1); + } + return ( 0 && ( Open files: - {openFiles.map((file: File) => ( - - - {path.basename(file.path)} - {file.isActive ? ' (active)' : ''} - - ))} + {openFiles.map((file: File) => { + const basename = path.basename(file.path); + const isDuplicate = (basenameCounts.get(basename) || 0) > 1; + const parentDir = path.basename(path.dirname(file.path)); + const displayName = isDuplicate + ? `${basename} (/${parentDir})` + : basename; + + return ( + + - {displayName} + {file.isActive ? ' (active)' : ''} + + ); + })} )} diff --git a/packages/cli/src/ui/components/__snapshots__/IDEContextDetailDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/IDEContextDetailDisplay.test.tsx.snap new file mode 100644 index 00000000..8b84e1f3 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/IDEContextDetailDisplay.test.tsx.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`IDEContextDetailDisplay > handles duplicate basenames by showing path hints 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ VS Code Context (ctrl+e to toggle) │ +│ │ +│ Open files: │ +│ - bar.txt (/foo) (active) │ +│ - bar.txt (/qux) │ +│ - unique.txt │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`IDEContextDetailDisplay > renders a list of open files with active status 1`] = ` +" +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ VS Code Context (ctrl+e to toggle) │ +│ │ +│ Open files: │ +│ - bar.txt (active) │ +│ - baz.txt │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 42b79c44..508dfea1 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as fs from 'node:fs'; import { detectIde, DetectedIde, @@ -23,6 +24,8 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ const logger = { // eslint-disable-next-line @typescript-eslint/no-explicit-any debug: (...args: any[]) => console.debug('[DEBUG] [IDEClient]', ...args), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: (...args: any[]) => console.error('[ERROR] [IDEClient]', ...args), }; export type IDEConnectionState = { @@ -36,6 +39,16 @@ export enum IDEConnectionStatus { Connecting = 'connecting', } +function getRealPath(path: string): string { + try { + return fs.realpathSync(path); + } catch (_e) { + // If realpathSync fails, it might be because the path doesn't exist. + // In that case, we can fall back to the original path. + return path; + } +} + /** * Manages the connection to and interaction with the IDE server. */ @@ -69,7 +82,15 @@ export class IdeClient { this.setState(IDEConnectionStatus.Connecting); if (!this.currentIde || !this.currentIdeDisplayName) { - this.setState(IDEConnectionStatus.Disconnected); + this.setState( + IDEConnectionStatus.Disconnected, + `IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values( + DetectedIde, + ) + .map((ide) => getIdeDisplayName(ide)) + .join(', ')}`, + true, + ); return; } @@ -174,7 +195,11 @@ export class IdeClient { return this.currentIdeDisplayName; } - private setState(status: IDEConnectionStatus, details?: string) { + private setState( + status: IDEConnectionStatus, + details?: string, + logToConsole = false, + ) { const isAlreadyDisconnected = this.state.status === IDEConnectionStatus.Disconnected && status === IDEConnectionStatus.Disconnected; @@ -186,7 +211,10 @@ export class IdeClient { } if (status === IDEConnectionStatus.Disconnected) { - logger.debug('IDE integration disconnected:', details); + if (logToConsole) { + logger.error(details); + } + logger.debug(details); ideContext.clearIdeContext(); } } @@ -197,6 +225,7 @@ export class IdeClient { this.setState( IDEConnectionStatus.Disconnected, `Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`, + true, ); return false; } @@ -204,13 +233,15 @@ export class IdeClient { this.setState( IDEConnectionStatus.Disconnected, `To use this feature, please open a single workspace folder in ${this.currentIdeDisplayName} and try again.`, + true, ); return false; } - if (ideWorkspacePath !== process.cwd()) { + if (getRealPath(ideWorkspacePath) !== getRealPath(process.cwd())) { this.setState( IDEConnectionStatus.Disconnected, `Directory mismatch. Gemini CLI is running in a different location than the open workspace in ${this.currentIdeDisplayName}. Please run the CLI from the same directory as your project's root folder.`, + true, ); return false; } @@ -223,6 +254,7 @@ export class IdeClient { this.setState( IDEConnectionStatus.Disconnected, `Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`, + true, ); return undefined; } @@ -244,12 +276,14 @@ export class IdeClient { this.setState( IDEConnectionStatus.Disconnected, `IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`, + true, ); }; this.client.onclose = () => { this.setState( IDEConnectionStatus.Disconnected, `IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`, + true, ); }; this.client.setNotificationHandler( @@ -299,6 +333,7 @@ export class IdeClient { this.setState( IDEConnectionStatus.Disconnected, `Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install.`, + true, ); if (transport) { try { From f1663d9615edee825b4f3e077183aeb4c781361b Mon Sep 17 00:00:00 2001 From: Shreya Keshive Date: Thu, 7 Aug 2025 17:25:06 -0400 Subject: [PATCH 044/107] README + reduce required VS Code version for companion extension (#5719) --- package-lock.json | 18 +++++++++--------- packages/vscode-ide-companion/README.md | 6 +++++- packages/vscode-ide-companion/package.json | 4 ++-- .../vscode-ide-companion/src/diff-manager.ts | 10 +++------- packages/vscode-ide-companion/src/extension.ts | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index e254cab8..2efb7e40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2511,13 +2511,6 @@ "boxen": "^7.1.1" } }, - "node_modules/@types/vscode": { - "version": "1.102.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.102.0.tgz", - "integrity": "sha512-V9sFXmcXz03FtYTSUsYsu5K0Q9wH9w9V25slddcxrh5JgORD14LpnOA7ov0L9ALi+6HrTjskLJ/tY5zeRF3TFA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -12029,7 +12022,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "20.x", - "@types/vscode": "^1.101.0", + "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "esbuild": "^0.25.3", @@ -12039,8 +12032,15 @@ "vitest": "^3.2.4" }, "engines": { - "vscode": "^1.101.0" + "vscode": "^1.99.0" } + }, + "packages/vscode-ide-companion/node_modules/@types/vscode": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.99.0.tgz", + "integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==", + "dev": true, + "license": "MIT" } } } diff --git a/packages/vscode-ide-companion/README.md b/packages/vscode-ide-companion/README.md index 1b96d7f3..49de94a1 100644 --- a/packages/vscode-ide-companion/README.md +++ b/packages/vscode-ide-companion/README.md @@ -8,11 +8,15 @@ The Gemini CLI Companion extension seamlessly integrates [Gemini CLI](https://gi - Selection Context: Gemini CLI can easily access your cursor's position and selected text within the editor, giving it valuable context directly from your current work. +- Native Diffing: Seamlessly view, modify, and accept code changes suggested by Gemini CLI directly within the editor. + +- Launch Gemini CLI: Quickly start a new Gemini CLI session from the Command Palette (Cmd+Shift+P or Ctrl+Shift+P) by running the "Gemini CLI: Run" command. + # Requirements To use this extension, you'll need: -- VS Code version 1.101.0 or newer +- VS Code version 1.99.0 or newer - Gemini CLI (installed separately) running within the VS Code integrated terminal # Terms of Service and Privacy Notice diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index aee14e32..2ed5cd21 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -11,7 +11,7 @@ "directory": "packages/vscode-ide-companion" }, "engines": { - "vscode": "^1.101.0" + "vscode": "^1.99.0" }, "license": "LICENSE", "preview": true, @@ -113,7 +113,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "20.x", - "@types/vscode": "^1.101.0", + "@types/vscode": "^1.99.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "esbuild": "^0.25.3", diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts index 0dad03a6..d2a53b54 100644 --- a/packages/vscode-ide-companion/src/diff-manager.ts +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -56,7 +56,7 @@ export class DiffManager { private diffDocuments = new Map(); constructor( - private readonly logger: vscode.OutputChannel, + private readonly log: (message: string) => void, private readonly diffContentProvider: DiffContentProvider, ) {} @@ -151,9 +151,7 @@ export class DiffManager { async acceptDiff(rightDocUri: vscode.Uri) { const diffInfo = this.diffDocuments.get(rightDocUri.toString()); if (!diffInfo) { - this.logger.appendLine( - `No diff info found for ${rightDocUri.toString()}`, - ); + this.log(`No diff info found for ${rightDocUri.toString()}`); return; } @@ -179,9 +177,7 @@ export class DiffManager { async cancelDiff(rightDocUri: vscode.Uri) { const diffInfo = this.diffDocuments.get(rightDocUri.toString()); if (!diffInfo) { - this.logger.appendLine( - `No diff info found for ${rightDocUri.toString()}`, - ); + this.log(`No diff info found for ${rightDocUri.toString()}`); // Even if we don't have diff info, we should still close the editor. await this.closeDiffEditor(rightDocUri); return; diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 08389731..18217140 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -42,7 +42,7 @@ export async function activate(context: vscode.ExtensionContext) { updateWorkspacePath(context); const diffContentProvider = new DiffContentProvider(); - const diffManager = new DiffManager(logger, diffContentProvider); + const diffManager = new DiffManager(log, diffContentProvider); context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { From 908ce2be337087de03df4986d12dd36cedd40f1e Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Thu, 7 Aug 2025 14:57:54 -0700 Subject: [PATCH 045/107] update: `google-github-actions/run-gemini-cli` version in workflows (#5802) --- .../gemini-automated-issue-triage.yml | 19 ++++++++++--------- .../gemini-scheduled-issue-triage.yml | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index d29484f7..c9381833 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -55,7 +55,7 @@ jobs: private-key: '${{ secrets.PRIVATE_KEY }}' - name: 'Run Gemini Issue Triage' - uses: 'google-github-actions/run-gemini-cli@68d5a6d2e31ff01029205c58c6bf81cb3d72910b' + uses: 'google-github-actions/run-gemini-cli@20351b5ea2b4179431f1ae8918a246a0808f8747' id: 'gemini_issue_triage' env: GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' @@ -87,26 +87,27 @@ jobs: prompt: |- ## Role - You are an issue triage assistant. Analyze the current GitHub issues apply the most appropriate existing labels. Do not remove labels titled help wanted or good first issue. + You are an issue triage assistant. Analyze the current GitHub issue and apply the most appropriate existing labels. Use the available + tools to gather information; do not ask for information to be provided. Do not remove labels titled help wanted or good first issue. ## Steps 1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels. - 2. Review the issue title, body and any comments provided in the environment variables. + 2. Review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}". 3. Ignore any existing priorities or tags on the issue. Just report your findings. 4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case. - 6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"` - 7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label - 8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label - 9. Use Area definitions mentioned below to help you narrow down issues + 6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`. + 7. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5 for anything more than 6 versions older than the most recent should add the status/need-retesting label. + 8. If you see that the issue doesn’t look like it has sufficient information recommend the status/need-information label. + 9. Use Area definitions mentioned below to help you narrow down issues. ## Guidelines - Only use labels that already exist in the repository. - Do not add comments or modify the issue content. - Triage only the current issue. - - Apply only one area/ label - - Apply only one kind/ label + - Apply only one area/ label. + - Apply only one kind/ label. - Apply all applicable sub-area/* and priority/* labels based on the issue content. It's ok to have multiple of these. - Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario. Categorization Guidelines: diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml index 4e6f8a00..97a81332 100644 --- a/.github/workflows/gemini-scheduled-issue-triage.yml +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -65,7 +65,7 @@ jobs: - name: 'Run Gemini Issue Triage' if: |- ${{ steps.find_issues.outputs.issues_to_triage != '[]' }} - uses: 'google-github-actions/run-gemini-cli@68d5a6d2e31ff01029205c58c6bf81cb3d72910b' + uses: 'google-github-actions/run-gemini-cli@20351b5ea2b4179431f1ae8918a246a0808f8747' id: 'gemini_issue_triage' env: GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' From c38147a3a61779ee9b82f656f03b51fc0e68e799 Mon Sep 17 00:00:00 2001 From: Richie Foreman Date: Thu, 7 Aug 2025 18:16:57 -0400 Subject: [PATCH 046/107] chore(vscode settings): Update VsCode settings for quality-of-life (#5806) Co-authored-by: Jacob Richman --- .vscode/settings.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e9031c3..25fad7e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,16 @@ { - "typescript.tsserver.experimental.enableProjectDiagnostics": true + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "editor.tabSize": 2, + "editor.rulers": [80], + "editor.detectIndentation": false, + "editor.insertSpaces": true, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } } From 9fc7115b8654fc193f948570293485f16d89f60a Mon Sep 17 00:00:00 2001 From: Bryant Chandler Date: Thu, 7 Aug 2025 15:24:55 -0700 Subject: [PATCH 047/107] perf(filesearch): Use async fzf for non-blocking file search (#5771) Co-authored-by: Jacob Richman --- .../cli/src/ui/hooks/useAtCompletion.test.ts | 6 +-- packages/cli/src/ui/hooks/useAtCompletion.ts | 2 +- .../core/src/utils/filesearch/fileSearch.ts | 49 +++++++++++-------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index aa198fc1..599f8fdf 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -157,7 +157,7 @@ describe('useAtCompletion', () => { }); }); - it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => { + it('should NOT show a loading indicator for subsequent searches that complete under 200ms', async () => { const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; testRootDir = await createTmpDir(structure); @@ -186,7 +186,7 @@ describe('useAtCompletion', () => { expect(result.current.isLoadingSuggestions).toBe(false); }); - it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => { + it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 200ms', async () => { const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' }; testRootDir = await createTmpDir(structure); @@ -194,7 +194,7 @@ describe('useAtCompletion', () => { const originalSearch = FileSearch.prototype.search; vi.spyOn(FileSearch.prototype, 'search').mockImplementation( async function (...args) { - await new Promise((resolve) => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 300)); return originalSearch.apply(this, args); }, ); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 82439c14..f6835dc8 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -194,7 +194,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void { slowSearchTimer.current = setTimeout(() => { dispatch({ type: 'SET_LOADING', payload: true }); - }, 100); + }, 200); try { const results = await fileSearch.current.search(state.pattern, { diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 76a099f7..480d5815 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -11,7 +11,7 @@ import picomatch from 'picomatch'; import { Ignore } from './ignore.js'; import { ResultCache } from './result-cache.js'; import * as cache from './crawlCache.js'; -import { Fzf, FzfResultItem } from 'fzf'; +import { AsyncFzf, FzfResultItem } from 'fzf'; export type FileSearchOptions = { projectRoot: string; @@ -78,18 +78,6 @@ export async function filter( return results; } -/** - * Filters a list of paths based on a given pattern using fzf. - * @param allPaths The list of all paths to filter. - * @param pattern The fzf pattern to filter by. - * @returns The filtered and sorted list of paths. - */ -function filterByFzf(allPaths: string[], pattern: string) { - return new Fzf(allPaths) - .find(pattern) - .map((entry: FzfResultItem) => entry.item); -} - export type SearchOptions = { signal?: AbortSignal; maxResults?: number; @@ -105,6 +93,7 @@ export class FileSearch { private readonly ignore: Ignore = new Ignore(); private resultCache: ResultCache | undefined; private allFiles: string[] = []; + private fzf: AsyncFzf | undefined; /** * Constructs a new `FileSearch` instance. @@ -136,24 +125,38 @@ export class FileSearch { pattern: string, options: SearchOptions = {}, ): Promise { - if (!this.resultCache) { + if (!this.resultCache || !this.fzf) { throw new Error('Engine not initialized. Call initialize() first.'); } pattern = pattern || '*'; + let filteredCandidates; const { files: candidates, isExactMatch } = await this.resultCache!.get(pattern); - let filteredCandidates; if (isExactMatch) { + // Use the cached result. filteredCandidates = candidates; } else { - // Apply the user's picomatch pattern filter - filteredCandidates = pattern.includes('*') - ? await filter(candidates, pattern, options.signal) - : filterByFzf(this.allFiles, pattern); - this.resultCache!.set(pattern, filteredCandidates); + let shouldCache = true; + if (pattern.includes('*')) { + filteredCandidates = await filter(candidates, pattern, options.signal); + } else { + filteredCandidates = await this.fzf + .find(pattern) + .then((results: Array>) => + results.map((entry: FzfResultItem) => entry.item), + ) + .catch(() => { + shouldCache = false; + return []; + }); + } + + if (shouldCache) { + this.resultCache!.set(pattern, filteredCandidates); + } } // Trade-off: We apply a two-stage filtering process. @@ -287,5 +290,11 @@ export class FileSearch { */ private buildResultCache(): void { this.resultCache = new ResultCache(this.allFiles, this.absoluteDir); + // The v1 algorithm is much faster since it only looks at the first + // occurence of the pattern. We use it for search spaces that have >20k + // files, because the v2 algorithm is just too slow in those cases. + this.fzf = new AsyncFzf(this.allFiles, { + fuzzy: this.allFiles.length > 20000 ? 'v1' : 'v2', + }); } } From 0c32a4061dc008f6483918a9e53cba8914e88bef Mon Sep 17 00:00:00 2001 From: Allen Hutchison Date: Thu, 7 Aug 2025 15:38:21 -0700 Subject: [PATCH 048/107] fix(core): Replace flaky performance tests with robust correctness tests (#5795) Co-authored-by: Jacob Richman --- .../core/src/tools/read-many-files.test.ts | 22 ++-- packages/core/src/utils/bfsFileSearch.test.ts | 101 ++++++------------ 2 files changed, 38 insertions(+), 85 deletions(-) diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts index 4035a6b7..c6b34665 100644 --- a/packages/core/src/tools/read-many-files.test.ts +++ b/packages/core/src/tools/read-many-files.test.ts @@ -523,7 +523,7 @@ describe('ReadManyFilesTool', () => { fs.writeFileSync(fullPath, content); }; - it('should process files in parallel for performance', async () => { + it('should process files in parallel', async () => { // Mock detectFileType to add artificial delay to simulate I/O const detectFileTypeSpy = vi.spyOn( await import('../utils/fileUtils.js'), @@ -534,31 +534,21 @@ describe('ReadManyFilesTool', () => { const fileCount = 4; const files = createMultipleFiles(fileCount, 'Batch test'); - // Mock with 100ms delay per file to simulate I/O operations + // Mock with 10ms delay per file to simulate I/O operations detectFileTypeSpy.mockImplementation(async (_filePath: string) => { - await new Promise((resolve) => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 10)); return 'text'; }); - const startTime = Date.now(); const params = { paths: files }; const result = await tool.execute(params, new AbortController().signal); - const endTime = Date.now(); - - const processingTime = endTime - startTime; - - console.log( - `Processing time: ${processingTime}ms for ${fileCount} files`, - ); - - // Verify parallel processing performance improvement - // Parallel processing should complete in ~100ms (single file time) - // Sequential would take ~400ms (4 files × 100ms each) - expect(processingTime).toBeLessThan(200); // Should PASS with parallel implementation // Verify all files were processed const content = result.llmContent as string[]; expect(content).toHaveLength(fileCount); + for (let i = 0; i < fileCount; i++) { + expect(content.join('')).toContain(`Batch test ${i}`); + } // Cleanup mock detectFileTypeSpy.mockRestore(); diff --git a/packages/core/src/utils/bfsFileSearch.test.ts b/packages/core/src/utils/bfsFileSearch.test.ts index ce19f80e..f9d76e38 100644 --- a/packages/core/src/utils/bfsFileSearch.test.ts +++ b/packages/core/src/utils/bfsFileSearch.test.ts @@ -190,80 +190,43 @@ describe('bfsFileSearch', () => { }); }); - it('should perform parallel directory scanning efficiently (performance test)', async () => { - // Create a more complex directory structure for performance testing - console.log('\n🚀 Testing Parallel BFS Performance...'); + it('should find all files in a complex directory structure', async () => { + // Create a complex directory structure to test correctness at scale + // without flaky performance checks. + const numDirs = 50; + const numFilesPerDir = 2; + const numTargetDirs = 10; - // Create 50 directories with multiple levels for faster test execution - for (let i = 0; i < 50; i++) { - await createEmptyDir(`dir${i}`); - await createEmptyDir(`dir${i}`, 'subdir1'); - await createEmptyDir(`dir${i}`, 'subdir2'); - await createEmptyDir(`dir${i}`, 'subdir1', 'deep'); - if (i < 10) { - // Add target files in some directories - await createTestFile('content', `dir${i}`, 'GEMINI.md'); - await createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md'); - } + const dirCreationPromises: Array> = []; + for (let i = 0; i < numDirs; i++) { + dirCreationPromises.push(createEmptyDir(`dir${i}`)); + dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir1')); + dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir2')); + dirCreationPromises.push(createEmptyDir(`dir${i}`, 'subdir1', 'deep')); } + await Promise.all(dirCreationPromises); - // Run multiple iterations to ensure consistency - const iterations = 3; - const durations: number[] = []; - let foundFiles = 0; - let firstResultSorted: string[] | undefined; - - for (let i = 0; i < iterations; i++) { - const searchStartTime = performance.now(); - const result = await bfsFileSearch(testRootDir, { - fileName: 'GEMINI.md', - maxDirs: 200, - debug: false, - }); - const duration = performance.now() - searchStartTime; - durations.push(duration); - - // Verify consistency: all iterations should find the exact same files - if (firstResultSorted === undefined) { - foundFiles = result.length; - firstResultSorted = result.sort(); - } else { - expect(result.sort()).toEqual(firstResultSorted); - } - - console.log(`📊 Iteration ${i + 1}: ${duration.toFixed(2)}ms`); + const fileCreationPromises: Array> = []; + for (let i = 0; i < numTargetDirs; i++) { + // Add target files in some directories + fileCreationPromises.push( + createTestFile('content', `dir${i}`, 'GEMINI.md'), + ); + fileCreationPromises.push( + createTestFile('content', `dir${i}`, 'subdir1', 'GEMINI.md'), + ); } + const expectedFiles = await Promise.all(fileCreationPromises); - const avgDuration = durations.reduce((a, b) => a + b, 0) / durations.length; - const maxDuration = Math.max(...durations); - const minDuration = Math.min(...durations); + const result = await bfsFileSearch(testRootDir, { + fileName: 'GEMINI.md', + // Provide a generous maxDirs limit to ensure it doesn't prematurely stop + // in this large test case. Total dirs created is 200. + maxDirs: 250, + }); - console.log(`📊 Average Duration: ${avgDuration.toFixed(2)}ms`); - console.log( - `📊 Min/Max Duration: ${minDuration.toFixed(2)}ms / ${maxDuration.toFixed(2)}ms`, - ); - console.log(`📁 Found ${foundFiles} GEMINI.md files`); - console.log( - `🏎️ Processing ~${Math.round(200 / (avgDuration / 1000))} dirs/second`, - ); - - // Verify we found the expected files - expect(foundFiles).toBe(20); // 10 dirs * 2 files each - - // Performance expectation: check consistency rather than absolute time - const variance = maxDuration - minDuration; - const consistencyRatio = variance / avgDuration; - - // Ensure reasonable performance (generous limit for CI environments) - expect(avgDuration).toBeLessThan(2000); // Very generous limit - - // Ensure consistency across runs (variance should not be too high) - // More tolerant in CI environments where performance can be variable - const maxConsistencyRatio = process.env.CI ? 3.0 : 1.5; - expect(consistencyRatio).toBeLessThan(maxConsistencyRatio); // Max variance should be reasonable - - console.log( - `✅ Performance test passed: avg=${avgDuration.toFixed(2)}ms, consistency=${(consistencyRatio * 100).toFixed(1)}% (threshold: ${(maxConsistencyRatio * 100).toFixed(0)}%)`, - ); + // Verify we found the exact files we created + expect(result.length).toBe(numTargetDirs * numFilesPerDir); + expect(result.sort()).toEqual(expectedFiles.sort()); }); }); From 9bc0a4aff3136ae9bb16a03e4d14ba46a137141a Mon Sep 17 00:00:00 2001 From: Richie Foreman Date: Thu, 7 Aug 2025 18:50:48 -0400 Subject: [PATCH 049/107] chore(telemetry): Log `FIREBASE_STUDIO` when using Gemini CLI within Firebase Studio (#5790) --- .../clearcut-logger/clearcut-logger.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 649d82b6..45a657c7 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -51,6 +51,25 @@ export interface LogResponse { nextRequestWaitMs?: number; } +/** + * Determine the surface that the user is currently using. Surface is effectively the + * distribution channel in which the user is using Gemini CLI. Gemini CLI comes bundled + * w/ Firebase Studio and Cloud Shell. Users that manually download themselves will + * likely be "SURFACE_NOT_SET". + * + * This is computed based upon a series of environment variables these distribution + * methods might have in their runtimes. + */ +function determineSurface(): string { + if (process.env.CLOUD_SHELL === 'true') { + return 'CLOUD_SHELL'; + } else if (process.env.MONOSPACE_ENV === 'true') { + return 'FIREBASE_STUDIO'; + } else { + return process.env.SURFACE || 'SURFACE_NOT_SET'; + } +} + // Singleton class for batch posting log events to Clearcut. When a new event comes in, the elapsed time // is checked and events are flushed to Clearcut if at least a minute has passed since the last flush. export class ClearcutLogger { @@ -237,10 +256,7 @@ export class ClearcutLogger { } logStartSessionEvent(event: StartSessionEvent): void { - const surface = - process.env.CLOUD_SHELL === 'true' - ? 'CLOUD_SHELL' - : process.env.SURFACE || 'SURFACE_NOT_SET'; + const surface = determineSurface(); const data = [ { From 65e4b941ee96525895b5a11fcb95725817478775 Mon Sep 17 00:00:00 2001 From: Richie Foreman Date: Thu, 7 Aug 2025 18:54:00 -0400 Subject: [PATCH 050/107] chore(vscode): Add recommended extensions list to vscode settings. (#5810) --- .vscode/extensions.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..183356b0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["vitest.explorer", "esbenp.prettier-vscode"] +} From 4f2974dbfe36638915f1b08448d2563c64f88644 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:55:53 -0700 Subject: [PATCH 051/107] feat(ui): Improve UI layout adaptation for narrow terminals (#5651) Co-authored-by: Jacob Richman --- packages/cli/src/ui/App.test.tsx | 29 +++ packages/cli/src/ui/App.tsx | 15 +- .../src/ui/__snapshots__/App.test.tsx.snap | 17 +- packages/cli/src/ui/components/AsciiArt.ts | 11 ++ .../components/ContextSummaryDisplay.test.tsx | 85 +++++++++ .../ui/components/ContextSummaryDisplay.tsx | 54 +++--- .../cli/src/ui/components/Footer.test.tsx | 106 +++++++++++ packages/cli/src/ui/components/Footer.tsx | 167 ++++++++++-------- .../cli/src/ui/components/Header.test.tsx | 44 +++++ packages/cli/src/ui/components/Header.tsx | 22 ++- .../cli/src/ui/components/InputPrompt.tsx | 4 +- .../ui/components/LoadingIndicator.test.tsx | 70 ++++++++ .../src/ui/components/LoadingIndicator.tsx | 57 ++++-- .../src/ui/components/SuggestionsDisplay.tsx | 2 +- packages/cli/src/ui/utils/isNarrowWidth.ts | 9 + 15 files changed, 560 insertions(+), 132 deletions(-) create mode 100644 packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/Footer.test.tsx create mode 100644 packages/cli/src/ui/components/Header.test.tsx create mode 100644 packages/cli/src/ui/utils/isNarrowWidth.ts diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index a5c2a9c6..577133ca 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -28,6 +28,7 @@ import { checkForUpdates, UpdateObject } from './utils/updateCheck.js'; import { EventEmitter } from 'events'; import { updateEventEmitter } from '../utils/updateEventEmitter.js'; import * as auth from '../config/auth.js'; +import * as useTerminalSize from './hooks/useTerminalSize.js'; // Define a more complete mock server config based on actual Config interface MockServerConfig { @@ -243,6 +244,10 @@ vi.mock('../config/auth.js', () => ({ validateAuthMethod: vi.fn(), })); +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: vi.fn(), +})); + const mockedCheckForUpdates = vi.mocked(checkForUpdates); const { isGitRepository: mockedIsGitRepository } = vi.mocked( await import('@google/gemini-cli-core'), @@ -284,6 +289,11 @@ describe('App UI', () => { }; beforeEach(() => { + vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({ + columns: 120, + rows: 24, + }); + const ServerConfigMocked = vi.mocked(ServerConfig, true); mockConfig = new ServerConfigMocked({ embeddingModel: 'test-embedding-model', @@ -1062,4 +1072,23 @@ describe('App UI', () => { expect(validateAuthMethodSpy).not.toHaveBeenCalled(); }); }); + + describe('when in a narrow terminal', () => { + it('should render with a column layout', () => { + vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({ + columns: 60, + rows: 24, + }); + + const { lastFrame, unmount } = render( + , + ); + currentUnmount = unmount; + expect(lastFrame()).toMatchSnapshot(); + }); + }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index d311facf..a25b7a56 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -93,6 +93,7 @@ import { ShowMoreLines } from './components/ShowMoreLines.js'; import { PrivacyNotice } from './privacy/PrivacyNotice.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from '../utils/events.js'; +import { isNarrowWidth } from './utils/isNarrowWidth.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; @@ -433,6 +434,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { // Terminal and UI setup const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); + const isNarrow = isNarrowWidth(terminalWidth); const { stdin, setRawMode } = useStdin(); const isInitialMount = useRef(true); @@ -441,7 +443,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { 20, Math.floor(terminalWidth * widthFraction) - 3, ); - const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); + const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 0.8)); // Utility callbacks const isValidPath = useCallback((filePath: string): boolean => { @@ -835,11 +837,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { items={[ {!settings.merged.hideBanner && ( -
+
)} {!settings.merged.hideTips && } , @@ -994,9 +992,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { {process.env.GEMINI_SYSTEM_MD && ( @@ -1021,7 +1020,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { /> )} - + {showAutoAcceptIndicator !== ApprovalMode.DEFAULT && !shellModeActive && ( should render correctly with the prompt input box 1`] = ` `; exports[`App UI > should render the initial UI correctly 1`] = ` -" - I'm Feeling Lucky (esc to cancel, 0s) +" I'm Feeling Lucky (esc to cancel, 0s) /test/dir no sandbox (see /docs) model (100% context left)" `; + +exports[`App UI > when in a narrow terminal > should render with a column layout 1`] = ` +" + + +╭────────────────────────────────────────────────────────────────────────────────────────╮ +│ > Type your message or @path/to/file │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +dir + +no sandbox (see /docs) + +model (100% context left)| ✖ 5 errors (ctrl+o for details)" +`; diff --git a/packages/cli/src/ui/components/AsciiArt.ts b/packages/cli/src/ui/components/AsciiArt.ts index e15704dd..79eb522c 100644 --- a/packages/cli/src/ui/components/AsciiArt.ts +++ b/packages/cli/src/ui/components/AsciiArt.ts @@ -25,3 +25,14 @@ export const longAsciiLogo = ` ███░ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████ ░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ `; + +export const tinyAsciiLogo = ` + ███ █████████ +░░░███ ███░░░░░███ + ░░░███ ███ ░░░ + ░░░███░███ + ███░ ░███ █████ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ +`; diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx new file mode 100644 index 00000000..d70bb4ca --- /dev/null +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi } from 'vitest'; +import { ContextSummaryDisplay } from './ContextSummaryDisplay.js'; +import * as useTerminalSize from '../hooks/useTerminalSize.js'; + +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: vi.fn(), +})); + +const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); + +const renderWithWidth = ( + width: number, + props: React.ComponentProps, +) => { + useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); + return render(); +}; + +describe('', () => { + const baseProps = { + geminiMdFileCount: 1, + contextFileNames: ['GEMINI.md'], + mcpServers: { 'test-server': { command: 'test' } }, + showToolDescriptions: false, + ideContext: { + workspaceState: { + openFiles: [{ path: '/a/b/c' }], + }, + }, + }; + + it('should render on a single line on a wide screen', () => { + const { lastFrame } = renderWithWidth(120, baseProps); + const output = lastFrame(); + expect(output).toContain( + 'Using: 1 open file (ctrl+e to view) | 1 GEMINI.md file | 1 MCP server (ctrl+t to view)', + ); + // Check for absence of newlines + expect(output.includes('\n')).toBe(false); + }); + + it('should render on multiple lines on a narrow screen', () => { + const { lastFrame } = renderWithWidth(60, baseProps); + const output = lastFrame(); + const expectedLines = [ + 'Using:', + ' - 1 open file (ctrl+e to view)', + ' - 1 GEMINI.md file', + ' - 1 MCP server (ctrl+t to view)', + ]; + const actualLines = output.split('\n'); + expect(actualLines).toEqual(expectedLines); + }); + + it('should switch layout at the 80-column breakpoint', () => { + // At 80 columns, should be on one line + const { lastFrame: wideFrame } = renderWithWidth(80, baseProps); + expect(wideFrame().includes('\n')).toBe(false); + + // At 79 columns, should be on multiple lines + const { lastFrame: narrowFrame } = renderWithWidth(79, baseProps); + expect(narrowFrame().includes('\n')).toBe(true); + expect(narrowFrame().split('\n').length).toBe(4); + }); + + it('should not render empty parts', () => { + const props = { + ...baseProps, + geminiMdFileCount: 0, + mcpServers: {}, + }; + const { lastFrame } = renderWithWidth(60, props); + const expectedLines = ['Using:', ' - 1 open file (ctrl+e to view)']; + const actualLines = lastFrame().split('\n'); + expect(actualLines).toEqual(expectedLines); + }); +}); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index 78a19f0d..99406bd6 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -5,9 +5,11 @@ */ import React from 'react'; -import { Text } from 'ink'; +import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { isNarrowWidth } from '../utils/isNarrowWidth.js'; interface ContextSummaryDisplayProps { geminiMdFileCount: number; @@ -26,6 +28,8 @@ export const ContextSummaryDisplay: React.FC = ({ showToolDescriptions, ideContext, }) => { + const { columns: terminalWidth } = useTerminalSize(); + const isNarrow = isNarrowWidth(terminalWidth); const mcpServerCount = Object.keys(mcpServers || {}).length; const blockedMcpServerCount = blockedMcpServers?.length || 0; const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0; @@ -78,30 +82,36 @@ export const ContextSummaryDisplay: React.FC = ({ } parts.push(blockedText); } - return parts.join(', '); + let text = parts.join(', '); + // Add ctrl+t hint when MCP servers are available + if (mcpServers && Object.keys(mcpServers).length > 0) { + if (showToolDescriptions) { + text += ' (ctrl+t to toggle)'; + } else { + text += ' (ctrl+t to view)'; + } + } + return text; })(); - let summaryText = 'Using: '; - const summaryParts = []; - if (openFilesText) { - summaryParts.push(openFilesText); - } - if (geminiMdText) { - summaryParts.push(geminiMdText); - } - if (mcpText) { - summaryParts.push(mcpText); - } - summaryText += summaryParts.join(' | '); + const summaryParts = [openFilesText, geminiMdText, mcpText].filter(Boolean); - // Add ctrl+t hint when MCP servers are available - if (mcpServers && Object.keys(mcpServers).length > 0) { - if (showToolDescriptions) { - summaryText += ' (ctrl+t to toggle)'; - } else { - summaryText += ' (ctrl+t to view)'; - } + if (isNarrow) { + return ( + + Using: + {summaryParts.map((part, index) => ( + + {' '}- {part} + + ))} + + ); } - return {summaryText}; + return ( + + Using: {summaryParts.join(' | ')} + + ); }; diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx new file mode 100644 index 00000000..5e79eea4 --- /dev/null +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi } from 'vitest'; +import { Footer } from './Footer.js'; +import * as useTerminalSize from '../hooks/useTerminalSize.js'; +import { tildeifyPath } from '@google/gemini-cli-core'; +import path from 'node:path'; + +vi.mock('../hooks/useTerminalSize.js'); +const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + shortenPath: (p: string, len: number) => { + if (p.length > len) { + return '...' + p.slice(p.length - len + 3); + } + return p; + }, + }; +}); + +const defaultProps = { + model: 'gemini-pro', + targetDir: + '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long', + branchName: 'main', + debugMode: false, + debugMessage: '', + corgiMode: false, + errorCount: 0, + showErrorDetails: false, + showMemoryUsage: false, + promptTokenCount: 100, + nightly: false, +}; + +const renderWithWidth = (width: number, props = defaultProps) => { + useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); + return render(