Compare commits

..

10 Commits

Author SHA1 Message Date
Spencer 8a5c470faf Merge branch 'main' into fix-telemetry-truncation 2026-04-23 15:45:50 -04:00
Spencer ae509f1ba5 test(integration): fix stdin buffering race condition in file-system interactive test 2026-04-21 20:44:02 +00:00
Spencer 65c3d7cabb test(integration): fix command loader race condition and test timeouts in interactive compression test 2026-04-21 20:44:02 +00:00
Spencer c72ac00a53 fix(telemetry): resolve OOM risk with Array.from, add surrogate pair protection, and fix try/catch key scope 2026-04-21 20:44:02 +00:00
Spencer 2bc481a3b5 fix(telemetry): strip high-cardinality attributes from metrics
Decouples generic OpenTelemetry attributes from metric-specific
attributes to resolve a Cloud Monitoring 'Internal error encountered'
caused by a cardinality explosion. High-cardinality values like
session.id, installation.id, user.email, and experiments.ids
are now excluded from time series metric labels, but they
are still preserved on traces and logs.

Also refactors getCommonAttributes to use getCommonMetricAttributes
to reduce repetition, and adds robust unit tests covering the
truncation of the experiments.ids array and fallback cases.
2026-04-21 20:44:01 +00:00
Spencer 169f9e457d fix: apply bounded structural truncation to Event Logs 2026-04-21 20:44:01 +00:00
Spencer 8481d977bb fix: add back global string limit gracefully returning a valid json string 2026-04-21 20:44:01 +00:00
Spencer dd4d7301ba fix: remove global string slice to guarantee JSON parseability 2026-04-21 20:44:01 +00:00
Spencer 5357770a3d fix: address structural truncation review comments 2026-04-21 20:44:01 +00:00
Spencer dc3ab31af0 fix(telemetry): implement bounded structural truncation 2026-04-21 20:44:01 +00:00
78 changed files with 484 additions and 2763 deletions
+1 -2
View File
@@ -2,8 +2,7 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"autoMemory": true,
"gemma": true
"autoMemory": true
},
"general": {
"devtools": true
@@ -1,45 +0,0 @@
name: '🧠 Gemini CLI Bot: Brain'
on:
schedule:
- cron: '0 0 * * *' # Every 24 hours
workflow_dispatch:
concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: true
permissions:
contents: 'write'
issues: 'write'
pull-requests: 'write'
jobs:
brain:
name: 'Brain (Reasoning Layer)'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Build Gemini CLI'
run: 'npm run bundle'
- name: 'Download Previous Metrics'
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # ratchet:actions/download-artifact@v4
with:
name: 'metrics-before'
path: 'tools/gemini-cli-bot/history/'
continue-on-error: true
@@ -1,59 +0,0 @@
name: '🔄 Gemini CLI Bot: Pulse'
on:
schedule:
- cron: '*/30 * * * *' # Every 30 minutes
workflow_dispatch:
concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: true
permissions:
contents: 'write'
issues: 'write'
pull-requests: 'write'
jobs:
pulse:
name: 'Pulse (Reflex Layer)'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Collect Metrics'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: 'npm run metrics'
- name: 'Archive Metrics'
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'metrics-before'
path: 'metrics-before.csv'
- name: 'Run Reflex Processes'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
if [ -d "tools/gemini-cli-bot/processes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/processes/scripts)" ]; then
for script in tools/gemini-cli-bot/processes/scripts/*.ts; do
echo "Running reflex script: $script"
npx tsx "$script"
done
else
echo "No reflex scripts found."
fi
-2
View File
@@ -371,8 +371,6 @@ for planned features and priorities.
## 📖 Resources
- **[Free Course](https://learn.deeplearning.ai/courses/gemini-cli-code-and-create-with-an-open-source-agent/information)** -
Learn the basics.
- **[Official Roadmap](./ROADMAP.md)** - See what's coming next.
- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent
notable updates.
-1
View File
@@ -163,7 +163,6 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
-4
View File
@@ -117,10 +117,6 @@ the following methods:
These methods will trust the current workspace for the duration of the session
without prompting.
For detailed instructions on managing folder trust within CI/CD workflows,
review the
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
## Overriding the trust file location
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
-51
View File
@@ -563,18 +563,6 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-2.5-flash-lite"
}
},
"gemma-4-31b-it": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemma-4-31b-it"
}
},
"gemma-4-26b-a4b-it": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemma-4-26b-a4b-it"
}
},
"gemini-2.5-flash-base": {
"extends": "base",
"modelConfig": {
@@ -846,28 +834,6 @@ their corresponding top-level category object in your `settings.json` file.
"multimodalToolUse": false
}
},
"gemma-4-31b-it": {
"displayName": "gemma-4-31b-it",
"tier": "custom",
"family": "gemma-4",
"isPreview": false,
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"gemma-4-26b-a4b-it": {
"displayName": "gemma-4-26b-a4b-it",
"tier": "custom",
"family": "gemma-4",
"isPreview": false,
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"auto": {
"tier": "auto",
"isPreview": true,
@@ -938,12 +904,6 @@ their corresponding top-level category object in your `settings.json` file.
```json
{
"gemma-4-31b-it": {
"default": "gemma-4-31b-it"
},
"gemma-4-26b-a4b-it": {
"default": "gemma-4-26b-a4b-it"
},
"gemini-3.1-pro-preview": {
"default": "gemini-3.1-pro-preview",
"contexts": [
@@ -1507,12 +1467,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.confirmationRequired`** (array):
- **Description:** Tool names that always require user confirmation. Takes
precedence over allowed tools and core tool allowlists.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.exclude`** (array):
- **Description:** Tool names to exclude from discovery.
- **Default:** `undefined`
@@ -1686,11 +1640,6 @@ their corresponding top-level category object in your `settings.json` file.
#### `experimental`
- **`experimental.gemma`** (boolean):
- **Description:** Enable access to Gemma 4 models (experimental).
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
- **Description:** Enable non-interactive agent sessions.
- **Default:** `false`
+3 -3
View File
@@ -17,7 +17,7 @@ describe('Hierarchical Memory', () => {
params: {
settings: {
security: {
folderTrust: { enabled: false },
folderTrust: { enabled: true },
},
},
},
@@ -55,7 +55,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
params: {
settings: {
security: {
folderTrust: { enabled: false },
folderTrust: { enabled: true },
},
},
},
@@ -96,7 +96,7 @@ Provide the answer as an XML block like this:
params: {
settings: {
security: {
folderTrust: { enabled: false },
folderTrust: { enabled: true },
},
},
},
-55
View File
@@ -18,11 +18,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingFavoriteColor,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `remember that my favorite color is blue.
@@ -45,11 +40,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCommandRestrictions,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I don't want you to ever run npm commands.`,
assert: async (rig, result) => {
@@ -71,11 +61,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingWorkflow,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I want you to always lint after building.`,
assert: async (rig, result) => {
@@ -98,11 +83,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringTemporaryInformation,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I'm going to get a coffee.`,
assert: async (rig, result) => {
@@ -128,11 +108,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingPetName,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `Please remember that my dog's name is Buddy.`,
assert: async (rig, result) => {
@@ -154,11 +129,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCommandAlias,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `When I say 'start server', you should run 'npm run dev'.`,
assert: async (rig, result) => {
@@ -181,11 +151,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: savingDbSchemaLocationAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
@@ -215,11 +180,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCodingStyle,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I prefer to use tabs instead of spaces for indentation.`,
assert: async (rig, result) => {
@@ -242,11 +202,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: savingBuildArtifactLocationAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
@@ -276,11 +231,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: savingMainEntryPointAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
@@ -309,11 +259,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingBirthday,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `My birthday is on June 15th.`,
assert: async (rig, result) => {
-1
View File
@@ -172,7 +172,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
timeout: evalCase.timeout,
env: {
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
GEMINI_CLI_TRUST_WORKSPACE: 'true',
},
});
@@ -58,7 +58,7 @@ describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
);
await run.expectText('Chat history compressed', 5000);
});
}, 60000);
// TODO: Context compression is broken and doesn't include the system
// instructions or tool counts, so it thinks compression is beneficial when
@@ -102,6 +102,9 @@ describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
});
const run = await rig.runInteractive();
await run.expectText('tips for getting started:', 5000);
// Wait for the async command loaders to finish so /compress is recognized
await new Promise((r) => setTimeout(r, 2000));
await run.type('/compress');
await run.type('\r');
@@ -116,5 +119,5 @@ describe.skipIf(skipOnDarwin)('Interactive Mode', () => {
foundEvent,
'chat_compression telemetry event should not be found for NOOP',
).toBe(false);
});
}, 60000);
});
@@ -33,6 +33,8 @@ describe('Interactive file system', () => {
rig.createFile(fileName, '1.0.0');
const run = await rig.runInteractive();
await run.expectText('tips for getting started:', 5000);
await new Promise((r) => setTimeout(r, 1000));
// Step 1: Read the file
const readPrompt = `Read the version from ${fileName}`;
@@ -56,5 +58,5 @@ describe('Interactive file system', () => {
// Wait for telemetry to flush and file system to sync, especially in sandboxed environments
await rig.waitForTelemetryReady();
});
}, 60000);
});
+4 -389
View File
@@ -991,37 +991,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
@@ -1069,24 +1038,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -1100,19 +1051,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/js": {
"version": "9.29.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
@@ -3795,37 +3733,6 @@
"path-browserify": "^1.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4986,13 +4893,6 @@
"win32"
]
},
"node_modules/@vscode/vsce/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -5032,30 +4932,6 @@
"node": ">=4"
}
},
"node_modules/@vscode/vsce/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@vscode/vsce/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -6553,13 +6429,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -7075,23 +6944,6 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/depcheck/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/depcheck/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/depcheck/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -7151,22 +7003,6 @@
"node": ">=6"
}
},
"node_modules/depcheck/node_modules/minimatch": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.9.tgz",
"integrity": "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/depcheck/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -8057,24 +7893,6 @@
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
}
},
"node_modules/eslint-plugin-import/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-import/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
@@ -8085,19 +7903,6 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-import/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-plugin-import/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -8154,37 +7959,6 @@
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-react/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-react/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-plugin-react/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -8243,37 +8017,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -11914,12 +11657,12 @@
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -12100,37 +11843,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/multimatch/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/multimatch/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/multimatch/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -12371,24 +12083,6 @@
"node": ">=4"
}
},
"node_modules/npm-run-all/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/npm-run-all/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/npm-run-all/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -12438,19 +12132,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/npm-run-all/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/npm-run-all/node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -15817,39 +15498,6 @@
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
@@ -16608,23 +16256,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript-eslint/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/typescript-eslint/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -16635,22 +16266,6 @@
"node": ">= 4"
}
},
"node_modules/typescript-eslint/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+2 -2
View File
@@ -63,7 +63,6 @@
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json",
"metrics": "tsx tools/gemini-cli-bot/metrics/index.ts",
"preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run bundle",
"prepare:package": "node scripts/prepare-package.js",
@@ -82,7 +81,8 @@
"glob": "^12.0.0",
"node-domexception": "npm:empty@^0.10.1",
"prebuild-install": "npm:nop@1.0.0",
"cross-spawn": "^7.0.6"
"cross-spawn": "^7.0.6",
"minimatch": "^10.2.2"
},
"bin": {
"gemini": "bundle/gemini.js"
@@ -112,7 +112,6 @@ export function createMockConfig(
}),
isContextManagementEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
getExperimentalGemma: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Config;
-12
View File
@@ -3055,18 +3055,6 @@ describe('loadCliConfig gemmaModelRouter', () => {
expect(gemmaSettings.classifier?.model).toBe('custom-gemma');
});
it('should load experimental.gemma setting from merged settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
gemma: true,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExperimentalGemma()).toBe(true);
});
it('should handle partial gemmaModelRouter settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
-1
View File
@@ -1011,7 +1011,6 @@ export async function loadCliConfig(
experimentalJitContext,
experimentalMemoryV2: settings.experimental?.memoryV2,
experimentalAutoMemory: settings.experimental?.autoMemory,
experimentalGemma: settings.experimental?.gemma,
contextManagement,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration:
-22
View File
@@ -1667,19 +1667,6 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
items: { type: 'string' },
},
confirmationRequired: {
type: 'array',
label: 'Confirmation Required',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: oneLine`
Tool names that always require user confirmation.
Takes precedence over allowed tools and core tool allowlists.
`,
showInDialog: false,
items: { type: 'string' },
},
exclude: {
type: 'array',
label: 'Exclude Tools',
@@ -2052,15 +2039,6 @@ const SETTINGS_SCHEMA = {
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {
gemma: {
type: 'boolean',
label: 'Gemma Models',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable access to Gemma 4 models (experimental).',
showInDialog: true,
},
adk: {
type: 'object',
label: 'ADK',
-1
View File
@@ -306,7 +306,6 @@ describe('gemini.tsx main function cleanup', () => {
getMessageBus: () => ({ subscribe: vi.fn() }),
getEnableHooks: vi.fn(() => true),
getHookSystem: vi.fn(() => undefined),
getExperimentalGemma: vi.fn(() => false),
initialize: vi.fn(),
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
getContentGeneratorConfig: vi.fn(),
@@ -89,7 +89,6 @@ describe('ShellProcessor', () => {
getPolicyEngine: vi.fn().mockReturnValue({
check: mockPolicyEngineCheck,
}),
getExperimentalGemma: vi.fn().mockReturnValue(false),
get config() {
return this as unknown as Config;
},
@@ -168,7 +168,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getExperimentalGemma: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
@@ -65,7 +65,6 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getExperimentalGemma: () => boolean;
getLastRetrievedQuota: () =>
| {
buckets: Array<{
@@ -86,7 +85,6 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getExperimentalGemma: () => false,
getLastRetrievedQuota: () => ({ buckets: [] }),
getSessionId: () => 'test-session-id',
};
+4 -23
View File
@@ -19,8 +19,6 @@ import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
ModelSlashCommandEvent,
logModelSlashCommand,
getDisplayString,
@@ -224,9 +222,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}
// --- LEGACY PATH ---
const showGemmaModels = config?.getExperimentalGemma() ?? false;
const options = [
const list = [
{
value: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
@@ -244,21 +240,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (showGemmaModels) {
options.push(
{
value: GEMMA_4_31B_IT_MODEL,
title: getDisplayString(GEMMA_4_31B_IT_MODEL),
key: GEMMA_4_31B_IT_MODEL,
},
{
value: GEMMA_4_26B_A4B_IT_MODEL,
title: getDisplayString(GEMMA_4_26B_A4B_IT_MODEL),
key: GEMMA_4_26B_A4B_IT_MODEL,
},
);
}
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
@@ -289,15 +270,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
});
}
options.unshift(...previewOptions);
list.unshift(...previewOptions);
}
if (!hasAccessToProModel) {
// Filter out all Pro models for free tier
return options.filter((option) => !isProModel(option.value));
return list.filter((option) => !isProModel(option.value));
}
return options;
return list;
}, [
shouldShowPreviewModels,
useGemini31,
@@ -86,7 +86,6 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getProjectTempDir: () => '/tmp/test',
},
getSessionId: () => 'default-session-id',
getExperimentalGemma: () => false,
...overrides,
}) as Config;
@@ -168,13 +168,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
@@ -1,56 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { HintMessage } from './HintMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
describe('HintMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal hint message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<HintMessage text="Try this instead" />,
{ width: 80 },
);
const output = lastFrame();
expect(output).toContain('💡');
expect(output).toContain('Steering Hint: Try this instead');
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<HintMessage text="Try this instead" />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('💡');
expect(lines[0]).toContain('Steering Hint: Try this instead');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -19,9 +19,7 @@ export const HintMessage: React.FC<HintMessageProps> = ({ text }) => {
const prefix = '💡 ';
const prefixWidth = prefix.length;
const config = useConfig();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
const useBackgroundColor = config.getUseBackgroundColor();
return (
<HalfLinePaddedBox
@@ -7,7 +7,6 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { UserMessage } from './UserMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
// Mock the commandUtils to control isSlashCommand behavior
vi.mock('../../utils/commandUtils.js', () => ({
@@ -15,11 +14,6 @@ vi.mock('../../utils/commandUtils.js', () => ({
}));
describe('UserMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal user message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserMessage text="Hello Gemini" width={80} />,
@@ -66,32 +60,4 @@ describe('UserMessage', () => {
expect(output).toMatchSnapshot();
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserMessage text="Hello Gemini" width={80} />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
// There should be empty lines above and below the message due to marginY={1}.
// lastFrame() returns the full buffer, so we can check for leading/trailing newlines or empty lines.
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('> Hello Gemini');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -27,9 +27,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text);
const config = useConfig();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
const useBackgroundColor = config.getUseBackgroundColor();
const textColor = isSlashCommand ? theme.text.accent : theme.text.primary;
@@ -1,54 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { UserShellMessage } from './UserShellMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
describe('UserShellMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal shell message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserShellMessage text="ls -la" width={80} />,
{ width: 80 },
);
const output = lastFrame();
expect(output).toContain('$ ls -la');
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserShellMessage text="ls -la" width={80} />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('$ ls -la');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -20,9 +20,7 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
width,
}) => {
const config = useConfig();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
const useBackgroundColor = config.getUseBackgroundColor();
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
@@ -1,7 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`HintMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
💡 Steering Hint: Try this instead
"
`;
@@ -28,9 +28,3 @@ exports[`UserMessage > transforms image paths in user message 1`] = `
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`UserMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
> Hello Gemini
"
`;
@@ -1,7 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserShellMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
$ ls -la
"
`;
@@ -69,7 +69,6 @@ describe('Session Cleanup (Refactored)', () => {
},
getSessionId: () => 'current123',
getDebugMode: () => false,
getExperimentalGemma: () => false,
initialize: async () => {},
...overrides,
} as unknown as Config;
@@ -96,9 +96,7 @@ const folderTrustCheck: WarningCheck = {
if (isHeadlessMode()) {
throw new FatalUntrustedWorkspaceError(
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, ' +
'set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode. ' +
'For more details, see https://geminicli.com/docs/cli/trusted-folders/#headless-and-automated-environments',
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode.',
);
}
-2
View File
@@ -12,8 +12,6 @@ export {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
} from './src/config/models.js';
export {
serializeTerminalToObject,
+1 -45
View File
@@ -960,11 +960,8 @@ describe('Server Config (config.ts)', () => {
});
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
await config.getExperimentsAsync();
await vi.waitFor(() => {
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should NOT switch to flash model if user has Pro access and model is auto', async () => {
@@ -3645,47 +3642,6 @@ describe('Config JIT Initialization', () => {
expect(config.isAutoMemoryEnabled()).toBe(true);
});
it('should return true when experimentalGemma is true', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalGemma: true,
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(true);
});
it('should return false when experimentalGemma is false', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalGemma: false,
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(false);
});
it('should return false when experimentalGemma is not provided', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(false);
});
it('should be independent of experimentalMemoryV2', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
+14 -28
View File
@@ -711,7 +711,6 @@ export interface ConfigParameters {
autoDistillation?: boolean;
experimentalMemoryV2?: boolean;
experimentalAutoMemory?: boolean;
experimentalGemma?: boolean;
experimentalContextManagementConfig?: string;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
@@ -957,7 +956,6 @@ export class Config implements McpContext, AgentLoopContext {
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryV2: boolean;
private readonly experimentalAutoMemory: boolean;
private readonly experimentalGemma: boolean;
private readonly experimentalContextManagementConfig?: string;
private readonly memoryBoundaryMarkers: readonly string[];
private readonly topicUpdateNarration: boolean;
@@ -1176,7 +1174,6 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? true;
this.experimentalMemoryV2 = params.experimentalMemoryV2 ?? true;
this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
this.experimentalGemma = params.experimentalGemma ?? false;
this.experimentalContextManagementConfig =
params.experimentalContextManagementConfig;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
@@ -1593,12 +1590,8 @@ export class Config implements McpContext, AgentLoopContext {
return undefined;
});
const [experiments] = await Promise.all([
this.experimentsPromise,
quotaPromise.catch((e) => {
debugLogger.error('Failed to fetch user quota', e);
}),
]);
// Fetch experiments and update timeouts before continuing initialization
const experiments = await this.experimentsPromise;
const requestTimeoutMs = this.getRequestTimeoutMs();
if (requestTimeoutMs !== undefined) {
@@ -1608,6 +1601,8 @@ export class Config implements McpContext, AgentLoopContext {
// Initialize BaseLlmClient now that the ContentGenerator and experiments are available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
await quotaPromise;
const authType = this.contentGeneratorConfig.authType;
if (
authType === AuthType.USE_GEMINI ||
@@ -1628,21 +1623,16 @@ export class Config implements McpContext, AgentLoopContext {
const adminControlsEnabled =
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
false;
try {
const adminControls = await fetchAdminControls(
codeAssistServer,
this.getRemoteAdminSettings(),
adminControlsEnabled,
(newSettings: AdminControlsSettings) => {
this.setRemoteAdminSettings(newSettings);
coreEvents.emitAdminSettingsChanged();
},
);
this.setRemoteAdminSettings(adminControls);
} catch (e) {
debugLogger.error('Failed to fetch admin controls', e);
}
const adminControls = await fetchAdminControls(
codeAssistServer,
this.getRemoteAdminSettings(),
adminControlsEnabled,
(newSettings: AdminControlsSettings) => {
this.setRemoteAdminSettings(newSettings);
coreEvents.emitAdminSettingsChanged();
},
);
this.setRemoteAdminSettings(adminControls);
if ((await this.getProModelNoAccess()) && isAutoModel(this.model)) {
this.setModel(PREVIEW_GEMINI_FLASH_MODEL);
@@ -2524,10 +2514,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalAutoMemory;
}
getExperimentalGemma(): boolean {
return this.experimentalGemma;
}
getExperimentalContextManagementConfig(): string | undefined {
return this.experimentalContextManagementConfig;
}
@@ -89,19 +89,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-2.5-flash-lite',
},
},
'gemma-4-31b-it': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemma-4-31b-it',
},
},
'gemma-4-26b-a4b-it': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemma-4-26b-a4b-it',
},
},
// Bases for the internal model configs.
'gemini-2.5-flash-base': {
extends: 'base',
@@ -330,23 +317,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
isVisible: true,
features: { thinking: false, multimodalToolUse: false },
},
'gemma-4-31b-it': {
displayName: 'gemma-4-31b-it',
tier: 'custom',
family: 'gemma-4',
isPreview: false,
isVisible: true,
features: { thinking: true, multimodalToolUse: false },
},
'gemma-4-26b-a4b-it': {
displayName: 'gemma-4-26b-a4b-it',
tier: 'custom',
family: 'gemma-4',
isPreview: false,
isVisible: true,
features: { thinking: true, multimodalToolUse: false },
},
// Aliases
auto: {
tier: 'auto',
@@ -392,13 +362,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
},
modelIdResolutions: {
'gemma-4-31b-it': {
default: 'gemma-4-31b-it',
},
'gemma-4-26b-a4b-it': {
default: 'gemma-4-26b-a4b-it',
},
'gemini-3.1-pro-preview': {
default: 'gemini-3.1-pro-preview',
contexts: [
-17
View File
@@ -32,8 +32,6 @@ import {
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isPreviewModel,
isProModel,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
} from './models.js';
import type { Config } from './config.js';
import { ModelConfigService } from '../services/modelConfigService.js';
@@ -358,10 +356,6 @@ describe('getDisplayString', () => {
it('should return the model name as is for other models', () => {
expect(getDisplayString('custom-model')).toBe('custom-model');
expect(getDisplayString(GEMMA_4_31B_IT_MODEL)).toBe(GEMMA_4_31B_IT_MODEL);
expect(getDisplayString(GEMMA_4_26B_A4B_IT_MODEL)).toBe(
GEMMA_4_26B_A4B_IT_MODEL,
);
expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe(
DEFAULT_GEMINI_FLASH_LITE_MODEL,
);
@@ -579,17 +573,6 @@ describe('isActiveModel', () => {
expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true);
});
it('should return true for Gemma 4 models only when experimentalGemma is true', () => {
expect(isActiveModel(GEMMA_4_31B_IT_MODEL)).toBe(false);
expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL)).toBe(false);
expect(isActiveModel(GEMMA_4_31B_IT_MODEL, false, false, false, true)).toBe(
true,
);
expect(
isActiveModel(GEMMA_4_26B_A4B_IT_MODEL, false, false, false, true),
).toBe(true);
});
it('should return false for Gemini 3.1 models when Gemini 3.1 is not launched', () => {
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(false);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(false);
-14
View File
@@ -61,9 +61,6 @@ export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
export const GEMMA_4_31B_IT_MODEL = 'gemma-4-31b-it';
export const GEMMA_4_26B_A4B_IT_MODEL = 'gemma-4-26b-a4b-it';
export const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
@@ -73,9 +70,6 @@ export const VALID_GEMINI_MODELS = new Set([
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
]);
export const PREVIEW_GEMINI_MODEL_AUTO = 'auto-gemini-3';
@@ -263,10 +257,6 @@ export function getDisplayString(
return 'Auto (Gemini 3)';
case DEFAULT_GEMINI_MODEL_AUTO:
return 'Auto (Gemini 2.5)';
case GEMMA_4_31B_IT_MODEL:
return GEMMA_4_31B_IT_MODEL;
case GEMMA_4_26B_A4B_IT_MODEL:
return GEMMA_4_26B_A4B_IT_MODEL;
case GEMINI_MODEL_ALIAS_PRO:
return PREVIEW_GEMINI_MODEL;
case GEMINI_MODEL_ALIAS_FLASH:
@@ -448,14 +438,10 @@ export function isActiveModel(
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
experimentalGemma: boolean = false,
): boolean {
if (!VALID_GEMINI_MODELS.has(model)) {
return false;
}
if (model === GEMMA_4_31B_IT_MODEL || model === GEMMA_4_26B_A4B_IT_MODEL) {
return experimentalGemma;
}
if (model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL) {
return useGemini3_1FlashLite;
}
-6
View File
@@ -10,23 +10,17 @@ import {
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
} from '../config/models.js';
type Model = string;
type TokenCount = number;
export const DEFAULT_TOKEN_LIMIT = 1_048_576;
export const GEMMA_4_TOKEN_LIMIT = 256_000;
export function tokenLimit(model: Model): TokenCount {
// Add other models as they become relevant or if specified by config
// Pulled from https://ai.google.dev/gemini-api/docs/models
switch (model) {
case GEMMA_4_31B_IT_MODEL:
case GEMMA_4_26B_A4B_IT_MODEL:
return GEMMA_4_TOKEN_LIMIT;
case PREVIEW_GEMINI_MODEL:
case PREVIEW_GEMINI_FLASH_MODEL:
case DEFAULT_GEMINI_MODEL:
+10 -87
View File
@@ -74,9 +74,7 @@ export const ADMIN_POLICY_TIER = 5;
export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9;
export const EXCLUDE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.4;
export const CONFIRMATION_REQUIRED_PRIORITY = USER_POLICY_TIER + 0.35;
export const ALLOWED_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.3;
export const CORE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.25;
export const TRUSTED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.2;
export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1;
@@ -436,21 +434,10 @@ export async function createPolicyEngineConfig(
}
}
const nonPlanModes = [
ApprovalMode.DEFAULT,
ApprovalMode.AUTO_EDIT,
ApprovalMode.YOLO,
];
const mapToolsToRules = (
tools: string[],
priority: number,
source: string,
modes?: ApprovalMode[],
addDefaultDenyForTools = false,
) => {
const toolsWithNarrowing = new Set<string>();
for (const tool of tools) {
// Tools that are explicitly allowed in the settings.
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
if (settings.tools?.allowed) {
for (const tool of settings.tools.allowed) {
// Check for legacy format: toolName(args)
const match = tool.match(/^([a-zA-Z0-9_-]+)\((.*)\)$/);
if (match) {
@@ -462,17 +449,15 @@ export async function createPolicyEngineConfig(
// Treat args as a command prefix for shell tool
if (toolName === SHELL_TOOL_NAME) {
toolsWithNarrowing.add(toolName);
const patterns = buildArgsPatterns(undefined, args);
for (const pattern of patterns) {
if (pattern) {
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority,
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
argsPattern: new RegExp(pattern),
source,
modes,
source: 'Settings (Tools Allowed)',
});
}
}
@@ -482,9 +467,8 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority,
source,
modes,
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
source: 'Settings (Tools Allowed)',
});
}
} else {
@@ -495,70 +479,11 @@ export async function createPolicyEngineConfig(
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority,
source,
modes,
priority: ALLOWED_TOOLS_FLAG_PRIORITY,
source: 'Settings (Tools Allowed)',
});
}
}
if (addDefaultDenyForTools) {
for (const toolName of toolsWithNarrowing) {
rules.push({
toolName,
decision: PolicyDecision.DENY,
priority: priority - 0.01,
source: `${source} (Narrowing Enforcement)`,
modes,
});
}
}
};
// Tools that are explicitly allowed in the settings.
// Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows)
if (settings.tools?.allowed) {
mapToolsToRules(
settings.tools.allowed,
ALLOWED_TOOLS_FLAG_PRIORITY,
'Settings (Tools Allowed)',
undefined,
true,
);
}
// Tools that explicitly require confirmation in the settings.
// Priority: CONFIRMATION_REQUIRED_PRIORITY (overrides allowed and core)
if (settings.tools?.confirmationRequired) {
for (const tool of settings.tools.confirmationRequired) {
rules.push({
toolName: SHELL_TOOL_NAMES.includes(tool) ? SHELL_TOOL_NAME : tool,
decision: PolicyDecision.ASK_USER,
priority: CONFIRMATION_REQUIRED_PRIORITY,
source: 'Settings (Confirmation Required)',
});
}
}
// Core tools that are restricted in the settings.
// Priority: CORE_TOOLS_FLAG_PRIORITY (user tier - core tool allowlist)
if (settings.tools?.core) {
mapToolsToRules(
settings.tools.core,
CORE_TOOLS_FLAG_PRIORITY,
'Settings (Core Tools)',
nonPlanModes,
);
// If core tools are restricted, we should add a default DENY rule for everything else
// at a slightly lower priority than the explicit allows.
rules.push({
toolName: '*',
decision: PolicyDecision.DENY,
priority: CORE_TOOLS_FLAG_PRIORITY - 0.01,
source: 'Settings (Core Tools Allowlist Enforcement)',
modes: nonPlanModes,
});
}
// MCP servers that are trusted in the settings.
@@ -576,7 +501,6 @@ export async function createPolicyEngineConfig(
decision: PolicyDecision.ALLOW,
priority: TRUSTED_MCP_SERVER_PRIORITY,
source: 'Settings (MCP Trusted)',
modes: nonPlanModes,
});
}
}
@@ -595,7 +519,6 @@ export async function createPolicyEngineConfig(
decision: PolicyDecision.ALLOW,
priority: ALLOWED_MCP_SERVER_PRIORITY,
source: 'Settings (MCP Allowed)',
modes: nonPlanModes,
});
}
}
@@ -1,76 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { createPolicyEngineConfig } from './config.js';
import { PolicyEngine } from './policy-engine.js';
import { PolicyDecision, ApprovalMode } from './types.js';
describe('PolicyEngine - Core Tools Mapping', () => {
it('should allow tools explicitly listed in settings.tools.core', async () => {
const settings = {
tools: {
core: ['run_shell_command(ls)', 'run_shell_command(git status)'],
},
};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
undefined,
true, // interactive
);
const engine = new PolicyEngine(config);
// Test simple tool name
const result1 = await engine.check(
{ name: 'run_shell_command', args: { command: 'ls' } },
undefined,
);
expect(result1.decision).toBe(PolicyDecision.ALLOW);
// Test tool name with args
const result2 = await engine.check(
{ name: 'run_shell_command', args: { command: 'git status' } },
undefined,
);
expect(result2.decision).toBe(PolicyDecision.ALLOW);
// Test tool not in core list
const result3 = await engine.check(
{ name: 'run_shell_command', args: { command: 'npm test' } },
undefined,
);
// Should be DENIED because of strict allowlist
expect(result3.decision).toBe(PolicyDecision.DENY);
});
it('should allow tools in tools.core even if they are restricted by default policies', async () => {
// By default run_shell_command is ASK_USER.
// Putting it in tools.core should make it ALLOW.
const settings = {
tools: {
core: ['run_shell_command'],
},
};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
undefined,
true,
);
const engine = new PolicyEngine(config);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'any command' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
+2 -34
View File
@@ -43,35 +43,6 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
}
return [command];
}),
parseCommandDetails: vi.fn().mockImplementation((command: string) => {
// Basic mock implementation for PolicyEngine test needs
const commands = command.includes('&&')
? command.split('&&').map((c) => c.trim())
: [command.trim()];
// Detect $(...) or `...` and add as sub-commands for recursion tests
const subCommands = [...commands];
for (const cmd of commands) {
const subMatch = cmd.match(/\$\((.*)\)/) || cmd.match(/`(.*)`/);
if (subMatch?.[1]) {
subCommands.push(subMatch[1].trim());
}
}
return {
details: subCommands.map((c, i) => ({
name: c.split(' ')[0],
text: c,
startIndex: i === 0 ? 0 : -1, // Simple root indication
})),
hasError: false,
};
}),
stripShellWrapper: vi.fn().mockImplementation((command: string) => {
// Simple mock for stripping wrappers
const match = command.match(/^(?:bash|sh|zsh)\s+-c\s+["'](.*)["']$/i);
return match ? match[1] : command;
}),
hasRedirection: vi.fn().mockImplementation(
(command: string) =>
// Simple mock: true if '>' is present, unless it looks like "-> arrow"
@@ -1891,6 +1862,7 @@ describe('PolicyEngine', () => {
});
it('should return ASK_USER in non-YOLO mode if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
@@ -1905,11 +1877,7 @@ describe('PolicyEngine', () => {
});
// Simulate parsing failure
const { parseCommandDetails } = await import('../utils/shell-utils.js');
vi.mocked(parseCommandDetails).mockReturnValueOnce({
details: [],
hasError: true,
});
vi.mocked(splitCommands).mockReturnValueOnce([]);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'complex command' } },
+75 -72
View File
@@ -7,10 +7,8 @@
import { type FunctionCall } from '@google/genai';
import {
SHELL_TOOL_NAMES,
REDIRECTION_NAMES,
initializeShellParsers,
parseCommandDetails,
stripShellWrapper,
splitCommands,
hasRedirection,
extractStringFromParseEntry,
} from '../utils/shell-utils.js';
@@ -361,8 +359,7 @@ export class PolicyEngine {
}
await initializeShellParsers();
const parsed = parseCommandDetails(command);
const subCommands = parsed?.details ?? [];
const subCommands = splitCommands(command);
if (subCommands.length === 0) {
// If the matched rule says DENY, we should respect it immediately even if parsing fails.
@@ -383,109 +380,115 @@ export class PolicyEngine {
);
// Parsing logic failed, we can't trust it. Use default decision ASK_USER (or DENY in non-interactive).
// We return the rule that matched so the evaluation loop terminates.
return {
decision: this.defaultDecision,
rule,
};
}
debugLogger.debug(
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
);
// If there are multiple parts, or if we just want to validate the single part against DENY rules
if (subCommands.length > 0) {
debugLogger.debug(
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
);
if (ruleDecision === PolicyDecision.DENY) {
return { decision: PolicyDecision.DENY, rule };
}
if (ruleDecision === PolicyDecision.DENY) {
return { decision: PolicyDecision.DENY, rule };
}
// Start with the decision from the rule or heuristics.
// If the tool call was already downgraded (e.g. by heuristics), we start there.
let aggregateDecision = ruleDecision;
// Start optimistically. If all parts are ALLOW, the whole is ALLOW.
// We will downgrade if any part is ASK_USER or DENY.
let aggregateDecision = PolicyDecision.ALLOW;
let responsibleRule: PolicyRule | undefined;
// If heuristics downgraded the decision, we don't blame the rule.
let responsibleRule: PolicyRule | undefined =
rule && ruleDecision === rule.decision ? rule : undefined;
// Check for redirection on the full command string.
// Redirection always downgrades ALLOW to ASK_USER (it never upgrades).
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
if (aggregateDecision === PolicyDecision.ALLOW) {
// Check for redirection on the full command string
if (this.shouldDowngradeForRedirection(command, allowRedirection)) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${command}`,
);
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined; // Inherent policy
}
}
for (const detail of subCommands) {
if (REDIRECTION_NAMES.has(detail.name)) {
continue;
}
const subCmd = detail.text.trim();
const isAtomic =
subCmd === command ||
(detail.startIndex === 0 && detail.text.length === command.length);
// Recursive check for shell wrappers (bash -c, etc.)
const stripped = stripShellWrapper(subCmd);
if (stripped !== subCmd) {
const wrapperResult = await this.check(
{ name: toolName, args: { command: stripped, dir_path } },
serverName,
toolAnnotations,
subagent,
true,
);
if (wrapperResult.decision === PolicyDecision.DENY)
return wrapperResult;
if (wrapperResult.decision === PolicyDecision.ASK_USER) {
if (aggregateDecision === PolicyDecision.ALLOW) {
responsibleRule = wrapperResult.rule;
for (const rawSubCmd of subCommands) {
const subCmd = rawSubCmd.trim();
// Prevent infinite recursion for the root command
if (subCmd === command) {
if (this.shouldDowngradeForRedirection(subCmd, allowRedirection)) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
);
// Redirection always downgrades ALLOW to ASK_USER
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined; // Inherent policy
}
} else {
responsibleRule ??= wrapperResult.rule;
// Atomic command matching the rule.
if (
ruleDecision === PolicyDecision.ASK_USER &&
aggregateDecision === PolicyDecision.ALLOW
) {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = rule;
}
}
aggregateDecision = PolicyDecision.ASK_USER;
continue;
}
}
if (!isAtomic) {
const subResult = await this.check(
{ name: toolName, args: { command: subCmd, dir_path } },
serverName,
toolAnnotations,
subagent,
true,
);
if (subResult.decision === PolicyDecision.DENY) return subResult;
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
const subDecision = subResult.decision;
if (subResult.decision === PolicyDecision.ASK_USER) {
if (aggregateDecision === PolicyDecision.ALLOW) {
responsibleRule = subResult.rule;
} else {
responsibleRule ??= subResult.rule;
}
aggregateDecision = PolicyDecision.ASK_USER;
// If any part is DENIED, the whole command is DENY
if (subDecision === PolicyDecision.DENY) {
return {
decision: PolicyDecision.DENY,
rule: subResult.rule,
};
}
// Downgrade if sub-command has redirection
// If any part requires ASK_USER, the whole command requires ASK_USER
if (subDecision === PolicyDecision.ASK_USER) {
aggregateDecision = PolicyDecision.ASK_USER;
if (!responsibleRule) {
responsibleRule = subResult.rule;
}
}
// Check for redirection in allowed sub-commands
if (
subResult.decision === PolicyDecision.ALLOW &&
subDecision === PolicyDecision.ALLOW &&
this.shouldDowngradeForRedirection(subCmd, allowRedirection)
) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
);
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
responsibleRule = undefined;
}
}
}
return {
decision: aggregateDecision,
// If we stayed at ALLOW, we return the original rule (if any).
// If we downgraded, we return the responsible rule (or undefined if implicit).
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
};
}
return {
decision: aggregateDecision,
rule: aggregateDecision === ruleDecision ? rule : responsibleRule,
decision: ruleDecision,
rule,
};
}
@@ -498,7 +501,6 @@ export class PolicyEngine {
serverName: string | undefined,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
skipHeuristics = false,
): Promise<CheckResult> {
// Case 1: Metadata injection is the primary and safest way to identify an MCP server.
// If we have explicit `_serverName` metadata (usually injected by tool-registry for active tools), use it.
@@ -592,7 +594,6 @@ export class PolicyEngine {
let ruleDecision = rule.decision;
if (
!skipHeuristics &&
isShellCommand &&
command &&
!('commandPrefix' in rule) &&
@@ -614,10 +615,12 @@ export class PolicyEngine {
subagent,
);
decision = shellResult.decision;
matchedRule = shellResult.rule;
break;
if (shellResult.rule) {
matchedRule = shellResult.rule;
break;
}
} else {
decision = ruleDecision;
decision = rule.decision;
matchedRule = rule;
break;
}
@@ -640,7 +643,7 @@ export class PolicyEngine {
);
if (toolName && SHELL_TOOL_NAMES.includes(toolName)) {
let heuristicDecision = this.defaultDecision;
if (!skipHeuristics && command) {
if (command) {
heuristicDecision = await this.applyShellHeuristics(
command,
heuristicDecision,
@@ -1,134 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { PolicyEngine } from './policy-engine.js';
import { PolicyDecision, ApprovalMode } from './types.js';
import { initializeShellParsers } from '../utils/shell-utils.js';
import { buildArgsPatterns } from './utils.js';
describe('PolicyEngine - Shell Safety Regression Suite', () => {
let engine: PolicyEngine;
beforeAll(async () => {
await initializeShellParsers();
});
const setupEngine = (allowedCommands: string[]) => {
const rules = allowedCommands.map((cmd) => ({
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
argsPattern: new RegExp(buildArgsPatterns(undefined, cmd)[0]!),
priority: 10,
}));
return new PolicyEngine({
rules,
approvalMode: ApprovalMode.DEFAULT,
defaultDecision: PolicyDecision.ASK_USER,
});
};
it('should block unauthorized chained command with &&', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi && ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should allow authorized chained command with &&', async () => {
engine = setupEngine(['echo', 'ls']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi && ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should block unauthorized chained command with ||', async () => {
engine = setupEngine(['false']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'false || ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should block unauthorized chained command with ;', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi; ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should block unauthorized command in pipe |', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi | grep "hi"' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should allow authorized command in pipe |', async () => {
engine = setupEngine(['echo', 'grep']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi | grep "hi"' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should block unauthorized chained command with &', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi & ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should allow authorized chained command with &', async () => {
engine = setupEngine(['echo', 'ls']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi & ls' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should block unauthorized command in nested substitution', async () => {
engine = setupEngine(['echo', 'cat']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo $(cat $(ls))' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
it('should allow authorized command in nested substitution', async () => {
engine = setupEngine(['echo', 'cat', 'ls']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo $(cat $(ls))' } },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should block command redirection if not explicitly allowed', async () => {
engine = setupEngine(['echo']);
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo hi > /tmp/test' } },
undefined,
);
// Inherent policy: redirection downgrades to ASK_USER
expect(result.decision).toBe(PolicyDecision.ASK_USER);
});
});
@@ -59,30 +59,6 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => {
return {
...actual,
initializeShellParsers: vi.fn(),
parseCommandDetails: (command: string) => {
if (Object.prototype.hasOwnProperty.call(commandMap, command)) {
const subcommands = commandMap[command];
return {
details: subcommands.map((text) => ({
name: text.split(' ')[0],
text,
startIndex: command.indexOf(text),
})),
hasError: subcommands.length === 0 && command.includes('&&&'),
};
}
return {
details: [
{
name: command.split(' ')[0],
text: command,
startIndex: 0,
},
],
hasError: false,
};
},
stripShellWrapper: (command: string) => command,
splitCommands: (command: string) => {
if (Object.prototype.hasOwnProperty.call(commandMap, command)) {
return commandMap[command];
@@ -1,97 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, describe, it, beforeAll, vi } from 'vitest';
import { PolicyEngine } from './policy-engine.js';
import { PolicyDecision } from './types.js';
import { initializeShellParsers } from '../utils/shell-utils.js';
// Mock node:os to ensure shell-utils logic always thinks it's on a POSIX-like system.
// This ensures that internal calls to getShellConfiguration() and isWindows()
// within the shell-utils module return 'bash' configuration, even on Windows CI.
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
default: {
...actual,
platform: () => 'linux',
},
platform: () => 'linux',
};
});
// Mock shell-utils to ensure consistent behavior across platforms (especially Windows CI)
// We want to test PolicyEngine logic with Bash syntax rules.
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/shell-utils.js')>();
return {
...actual,
getShellConfiguration: () => ({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
}),
};
});
describe('PolicyEngine Command Substitution Validation', () => {
beforeAll(async () => {
await initializeShellParsers();
});
const setupEngine = (blockedCmd: string) =>
new PolicyEngine({
defaultDecision: PolicyDecision.ALLOW,
rules: [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(`"command":"${blockedCmd}"`),
decision: PolicyDecision.DENY,
},
],
});
it('should block echo $(dangerous_cmd) when dangerous_cmd is explicitly blocked', async () => {
const engine = setupEngine('dangerous_cmd');
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo $(dangerous_cmd)' } },
'test-server',
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should block backtick substitution `dangerous_cmd`', async () => {
const engine = setupEngine('dangerous_cmd');
const result = await engine.check(
{ name: 'run_shell_command', args: { command: 'echo `dangerous_cmd`' } },
'test-server',
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should block commands inside subshells (dangerous_cmd)', async () => {
const engine = setupEngine('dangerous_cmd');
const result = await engine.check(
{ name: 'run_shell_command', args: { command: '(dangerous_cmd)' } },
'test-server',
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should handle nested substitutions deeply', async () => {
const engine = setupEngine('deep_danger');
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo $(ls $(deep_danger))' },
},
'test-server',
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
});
-2
View File
@@ -335,10 +335,8 @@ export interface PolicySettings {
allowed?: string[];
};
tools?: {
core?: string[];
exclude?: string[];
allowed?: string[];
confirmationRequired?: string[];
};
mcpServers?: Record<string, { trust?: boolean }>;
// User provided policies that will replace the USER level policies in ~/.gemini/policies
@@ -97,30 +97,6 @@
"topK": 64
}
},
"gemma-4-31b-it": {
"model": "gemma-4-31b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemma-4-26b-a4b-it": {
"model": "gemma-4-26b-a4b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-flash-base": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
@@ -97,30 +97,6 @@
"topK": 64
}
},
"gemma-4-31b-it": {
"model": "gemma-4-31b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemma-4-26b-a4b-it": {
"model": "gemma-4-26b-a4b-it",
"generateContentConfig": {
"temperature": 1,
"topP": 0.95,
"thinkingConfig": {
"includeThoughts": true,
"thinkingLevel": "HIGH"
},
"topK": 64
}
},
"gemini-2.5-flash-base": {
"model": "gemini-2.5-flash",
"generateContentConfig": {
+4 -2
View File
@@ -121,8 +121,10 @@ describe('Telemetry Metrics', () => {
return actualApi;
});
const { getCommonAttributes } = await import('./telemetryAttributes.js');
(getCommonAttributes as Mock).mockReturnValue({
const { getCommonMetricAttributes } = await import(
'./telemetryAttributes.js'
);
(getCommonMetricAttributes as Mock).mockReturnValue({
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
+2 -2
View File
@@ -24,7 +24,7 @@ import type {
TokenStorageInitializationEvent,
} from './types.js';
import { AuthType } from '../core/contentGenerator.js';
import { getCommonAttributes } from './telemetryAttributes.js';
import { getCommonMetricAttributes } from './telemetryAttributes.js';
import { sanitizeHookName } from './sanitize.js';
const EVENT_CHAT_COMPRESSION = 'gemini_cli.chat_compression';
@@ -104,7 +104,7 @@ const EXIT_FAIL_COUNT = 'gemini_cli.exit.fail.count';
const PLAN_EXECUTION_COUNT = 'gemini_cli.plan.execution.count';
const baseMetricDefinition = {
getCommonAttributes,
getCommonAttributes: getCommonMetricAttributes,
};
const COUNTER_DEFINITIONS = {
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
getCommonAttributes,
getCommonMetricAttributes,
} from './telemetryAttributes.js';
import type { Config } from '../config/config.js';
import { UserAccountManager } from '../utils/userAccountManager.js';
import { InstallationManager } from '../utils/installationManager.js';
vi.mock('../utils/userAccountManager.js');
vi.mock('../utils/installationManager.js');
describe('telemetryAttributes', () => {
let mockConfig: Partial<Config>;
beforeEach(() => {
vi.resetAllMocks();
mockConfig = {
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue(undefined),
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
};
(
UserAccountManager.prototype.getCachedGoogleAccount as Mock
).mockReturnValue(undefined);
(InstallationManager.prototype.getInstallationId as Mock).mockReturnValue(
'mock-install-id',
);
});
describe('getCommonMetricAttributes', () => {
it('should return interactive and auth_type when defined', () => {
mockConfig.getContentGeneratorConfig = vi
.fn()
.mockReturnValue({ authType: 'oauth-personal' });
const attributes = getCommonMetricAttributes(mockConfig as Config);
expect(attributes).toEqual({
interactive: true,
auth_type: 'oauth-personal',
});
});
it('should return only interactive when auth_type is not defined', () => {
const attributes = getCommonMetricAttributes(mockConfig as Config);
expect(attributes).toEqual({
interactive: true,
});
});
});
describe('getCommonAttributes', () => {
it('should include all common attributes', () => {
(
UserAccountManager.prototype.getCachedGoogleAccount as Mock
).mockReturnValue('test@google.com');
mockConfig.getExperiments = vi
.fn()
.mockReturnValue({ experimentIds: [123, 456] });
mockConfig.getContentGeneratorConfig = vi
.fn()
.mockReturnValue({ authType: 'adc' });
const attributes = getCommonAttributes(mockConfig as Config);
expect(attributes).toEqual({
'session.id': 'mock-session-id',
'installation.id': 'mock-install-id',
interactive: true,
'user.email': 'test@google.com',
auth_type: 'adc',
'experiments.ids': '123,456',
});
});
it('should safely truncate experiments string to not exceed 1000 characters and not cut mid-ID', () => {
// Generate a list of experiment IDs that will produce a string > 1000 chars
const expIds = [];
for (let i = 0; i < 200; i++) {
// e.g., 100000000 -> 9 chars + 1 comma = 10 chars per ID
expIds.push(100000000 + i);
}
mockConfig.getExperiments = vi
.fn()
.mockReturnValue({ experimentIds: expIds });
const attributes = getCommonAttributes(mockConfig as Config);
const expString = attributes['experiments.ids'] as string;
expect(expString.length).toBeLessThanOrEqual(1000);
// Verify the last ID is complete (not cut off) by checking if it's one of our expected IDs
const ids = expString.split(',');
const lastIdStr = ids[ids.length - 1];
const lastIdNumber = parseInt(lastIdStr, 10);
expect(lastIdNumber).toBeGreaterThanOrEqual(100000000);
expect(lastIdNumber).toBeLessThan(100000200);
// Also ensure no trailing comma
expect(expString.endsWith(',')).toBe(false);
});
});
});
@@ -12,19 +12,36 @@ import { UserAccountManager } from '../utils/userAccountManager.js';
const userAccountManager = new UserAccountManager();
const installationManager = new InstallationManager();
export function getCommonMetricAttributes(config: Config): Attributes {
const authType = config.getContentGeneratorConfig()?.authType;
return {
interactive: config.isInteractive(),
...(authType && { auth_type: authType }),
};
}
export function getCommonAttributes(config: Config): Attributes {
const email = userAccountManager.getCachedGoogleAccount();
const experiments = config.getExperiments();
const authType = config.getContentGeneratorConfig()?.authType;
let experimentsIdsStr = '';
if (experiments && experiments.experimentIds.length > 0) {
experimentsIdsStr = experiments.experimentIds.join(',');
if (experimentsIdsStr.length > 1000) {
experimentsIdsStr = experimentsIdsStr.substring(0, 1000);
const lastCommaIndex = experimentsIdsStr.lastIndexOf(',');
if (lastCommaIndex > 0) {
experimentsIdsStr = experimentsIdsStr.substring(0, lastCommaIndex);
}
}
}
return {
...getCommonMetricAttributes(config),
'session.id': config.getSessionId(),
'installation.id': installationManager.getInstallationId(),
interactive: config.isInteractive(),
...(email && { 'user.email': email }),
...(authType && { auth_type: authType }),
...(experiments &&
experiments.experimentIds.length > 0 && {
'experiments.ids': experiments.experimentIds,
}),
...(experimentsIdsStr && { 'experiments.ids': experimentsIdsStr }),
};
}
+78 -9
View File
@@ -52,26 +52,95 @@ describe('truncateForTelemetry', () => {
});
it('should correctly truncate strings with multi-byte unicode characters (emojis)', () => {
// 5 emojis, each is multiple bytes in UTF-16
// 5 emojis, each is a surrogate pair (2 code units)
const emojis = '👋🌍🚀🔥🎉';
// Truncating to length 5 (which is 2.5 emojis in UTF-16 length terms)
// truncateString will stop after the full grapheme clusters that fit within 5
const result = truncateForTelemetry(emojis, 5);
// Truncating to 2 code units
const result = truncateForTelemetry(emojis, 2);
expect(result).toBe('👋🌍...[TRUNCATED: original length 10]');
expect(result).toBe('👋...[TRUNCATED: original length 10]');
});
it('should stringify and truncate objects if exceeding maxLength', () => {
it('should stringify and structurally truncate objects if exceeding limits', () => {
const obj = { message: 'hello world', nested: { a: 1 } };
const stringified = JSON.stringify(obj);
const result = truncateForTelemetry(obj, 10);
expect(result).toBe(
stringified.substring(0, 10) +
`...[TRUNCATED: original length ${stringified.length}]`,
JSON.stringify({
message: 'hello worl...[TRUNCATED: original length 11]',
nested: { a: 1 },
}),
);
});
it('should structurally truncate arrays and depth', () => {
const obj = {
arr: [1, 2, 3],
deep: { level1: { level2: { level3: { a: 1 } } } },
};
const result = truncateForTelemetry(obj, 100, 2, 3);
expect(result).toBe(
JSON.stringify({
arr: [1, 2, '[TRUNCATED: Array of length 3]'],
deep: { level1: { level2: '[TRUNCATED: Max Depth Reached]' } },
}),
);
});
it('should handle objects with a toJSON method', () => {
const date = new Date('2026-04-13T00:00:00.000Z');
const result = truncateForTelemetry(date);
expect(result).toBe('2026-04-13T00:00:00.000Z');
});
it('should handle getters via direct property access', () => {
const obj = {
get myGetter() {
return 'getter value';
},
get errorGetter() {
throw new Error('getter error');
},
};
const result = truncateForTelemetry(obj);
expect(result).toBe(
JSON.stringify({
myGetter: 'getter value',
errorGetter: '[ERROR: Failed to read property]',
}),
);
});
it('should truncate extremely long keys', () => {
const longKey = 'a'.repeat(150);
const obj = {
[longKey]: 'value',
};
const result = truncateForTelemetry(obj);
const expectedKey = 'a'.repeat(100) + '...[TRUNCATED_KEY]';
expect(result).toBe(
JSON.stringify({
[expectedKey]: 'value',
}),
);
});
it('should enforce a global payload string limit without breaking JSON', () => {
const obj = {
a: 'x'.repeat(100),
b: 'y'.repeat(100),
};
// Capping global string length to 50
const result = truncateForTelemetry(obj, 100, 100, 4, 100, 50) as string;
// It should replace the entire object with a valid JSON string indicating truncation
expect(result).toBe(
'"[TRUNCATED: Payload exceeded global limit of 50 characters. Original length: 215]"',
);
// Prove it remains perfectly parseable JSON
expect(() => JSON.parse(result)).not.toThrow();
});
it('should stringify objects unchanged if within maxLength', () => {
const obj = { a: 1 };
expect(truncateForTelemetry(obj, 100)).toBe(JSON.stringify(obj));
+99 -18
View File
@@ -14,7 +14,6 @@ import {
import { debugLogger } from '../utils/debugLogger.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { truncateString } from '../utils/textUtils.js';
import {
GEN_AI_AGENT_DESCRIPTION,
GEN_AI_AGENT_NAME,
@@ -45,6 +44,17 @@ export const spanRegistry = new FinalizationRegistry((endSpan: () => void) => {
}
});
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function hasToJSON(value: unknown): value is { toJSON: () => unknown } {
if (!isRecord(value)) return false;
if (!('toJSON' in value)) return false;
const toJSONFn = value['toJSON'];
return typeof toJSONFn === 'function';
}
/**
* Truncates a value for inclusion in telemetry attributes.
*
@@ -54,27 +64,98 @@ export const spanRegistry = new FinalizationRegistry((endSpan: () => void) => {
*/
export function truncateForTelemetry(
value: unknown,
maxLength = 10000,
maxStringLength = 10000,
maxArrayLength = 100,
maxDepth = 4,
maxObjectKeys = 100,
maxGlobalStringLength = 50000,
): AttributeValue | undefined {
if (typeof value === 'string') {
return truncateString(
value,
maxLength,
`...[TRUNCATED: original length ${value.length}]`,
) as AttributeValue;
const truncateObj = (v: unknown, depth: number): unknown => {
if (typeof v === 'string') {
if (v.length > maxStringLength) {
let truncatedStr = v.slice(0, maxStringLength);
if (truncatedStr.length > 0 && /[\uD800-\uDBFF]$/.test(truncatedStr)) {
truncatedStr = truncatedStr.slice(0, -1);
}
return truncatedStr + `...[TRUNCATED: original length ${v.length}]`;
}
return v;
}
if (
typeof v === 'number' ||
typeof v === 'boolean' ||
v === null ||
v === undefined
) {
return v;
}
if (hasToJSON(v)) {
try {
return truncateObj(v.toJSON(), depth);
} catch {
// Ignore and fall back to manual structural iteration
}
}
if (typeof v === 'object') {
if (depth >= maxDepth) {
return `[TRUNCATED: Max Depth Reached]`;
}
if (Array.isArray(v)) {
if (v.length > maxArrayLength) {
const truncatedArray = v
.slice(0, maxArrayLength)
.map((item) => truncateObj(item, depth + 1));
truncatedArray.push(`[TRUNCATED: Array of length ${v.length}]`);
return truncatedArray;
}
return v.map((item) => truncateObj(item, depth + 1));
}
const newObj: Record<string, unknown> = {};
let numKeys = 0;
const recordV = isRecord(v) ? v : {};
for (const key in recordV) {
if (!Object.prototype.hasOwnProperty.call(recordV, key)) continue;
if (numKeys >= maxObjectKeys) {
newObj['__truncated'] =
`[TRUNCATED: Object with >${maxObjectKeys} keys]`;
break;
}
const truncatedKey =
key.length > 100 ? key.slice(0, 100) + '...[TRUNCATED_KEY]' : key;
try {
const val = recordV[key];
newObj[truncatedKey] = truncateObj(val, depth + 1);
} catch {
newObj[truncatedKey] = '[ERROR: Failed to read property]';
}
numKeys++;
}
return newObj;
}
return undefined;
};
const truncated = truncateObj(value, 0);
if (
typeof truncated === 'string' ||
typeof truncated === 'number' ||
typeof truncated === 'boolean'
) {
return truncated as AttributeValue;
}
if (typeof value === 'object' && value !== null) {
const stringified = safeJsonStringify(value);
return truncateString(
stringified,
maxLength,
`...[TRUNCATED: original length ${stringified.length}]`,
) as AttributeValue;
if (truncated === null || truncated === undefined) {
return undefined;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value as AttributeValue;
const stringified = safeJsonStringify(truncated);
if (stringified.length > maxGlobalStringLength) {
return `"[TRUNCATED: Payload exceeded global limit of ${maxGlobalStringLength} characters. Original length: ${stringified.length}]"`;
}
return undefined;
return stringified as AttributeValue;
}
function isAsyncIterable<T>(value: T): value is T & AsyncIterable<unknown> {
+6 -5
View File
@@ -31,6 +31,7 @@ import type { AgentTerminateMode } from '../agents/types.js';
import { getCommonAttributes } from './telemetryAttributes.js';
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { truncateForTelemetry } from './trace.js';
import {
toInputMessages,
toOutputMessages,
@@ -451,7 +452,7 @@ export class ApiRequestEvent implements BaseTelemetryEvent {
}
if (shouldIncludePayloads(config) && this.prompt.contents) {
attributes['gen_ai.input.messages'] = JSON.stringify(
attributes['gen_ai.input.messages'] = truncateForTelemetry(
toInputMessages(this.prompt.contents),
);
}
@@ -548,7 +549,7 @@ export class ApiErrorEvent implements BaseTelemetryEvent {
}
if (shouldIncludePayloads(config) && this.prompt.contents) {
attributes['gen_ai.input.messages'] = JSON.stringify(
attributes['gen_ai.input.messages'] = truncateForTelemetry(
toInputMessages(this.prompt.contents),
);
}
@@ -618,7 +619,7 @@ function toGenerateContentConfigAttributes(
'gen_ai.request.max_tokens': config.maxOutputTokens,
'gen_ai.output.type': toOutputType(config.responseMimeType),
'gen_ai.request.stop_sequences': config.stopSequences,
'gen_ai.system_instructions': JSON.stringify(
'gen_ai.system_instructions': truncateForTelemetry(
toSystemInstruction(config.systemInstruction),
),
};
@@ -716,7 +717,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
'gen_ai.response.finish_reasons': this.finish_reasons,
...(shouldIncludePayloads(config)
? {
'gen_ai.output.messages': JSON.stringify(
'gen_ai.output.messages': truncateForTelemetry(
toOutputMessages(this.response.candidates),
),
}
@@ -731,7 +732,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
}
if (shouldIncludePayloads(config) && this.prompt.contents) {
attributes['gen_ai.input.messages'] = JSON.stringify(
attributes['gen_ai.input.messages'] = truncateForTelemetry(
toInputMessages(this.prompt.contents),
);
}
@@ -1993,27 +1993,6 @@ describe('mcp-client', () => {
});
});
it('should unconditionally attach a data listener to stderr to prevent process hang', async () => {
const mockStderr = {
on: vi.fn(),
};
vi.spyOn(SdkClientStdioLib, 'StdioClientTransport').mockReturnValue({
stderr: mockStderr,
} as unknown as SdkClientStdioLib.StdioClientTransport);
await createTransport(
'test-server',
{
command: 'test-command',
},
false, // debugMode = false
MOCK_CONTEXT,
);
expect(mockStderr.on).toHaveBeenCalledWith('data', expect.any(Function));
});
it('sets an env variable GEMINI_CLI=1 for stdio MCP servers', async () => {
const mockedTransport = vi
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
+18 -27
View File
@@ -2291,40 +2291,31 @@ export async function createTransport(
transport = new XcodeMcpBridgeFixTransport(transport);
}
// The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`,
// which exposes `stderr` for debug logging. We need to unwrap it to attach the listener.
if (debugMode) {
// The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`,
// which exposes `stderr` for debug logging. We need to unwrap it to attach the listener.
const underlyingTransport =
transport instanceof XcodeMcpBridgeFixTransport
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(transport as unknown as { transport: unknown }).transport
: transport;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const underlyingTransport =
transport instanceof XcodeMcpBridgeFixTransport
? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(transport as any).transport
: transport;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const streamTransport = underlyingTransport as {
stderr?: {
on: (event: string, listener: (data: unknown) => void) => void;
};
};
if (
streamTransport &&
typeof streamTransport === 'object' &&
'stderr' in streamTransport &&
streamTransport.stderr &&
typeof streamTransport.stderr.on === 'function'
) {
streamTransport.stderr.on('data', (data: unknown) => {
if (debugMode) {
const stderrStr = String(data).trim();
if (
underlyingTransport instanceof StdioClientTransport &&
underlyingTransport.stderr
) {
underlyingTransport.stderr.on('data', (data) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const stderrStr = data.toString().trim();
debugLogger.debug(
`[DEBUG] [MCP STDERR (${mcpServerName})]: `,
stderrStr,
);
}
});
});
}
}
return transport;
}
+2 -4
View File
@@ -1993,15 +1993,13 @@ describe('getRipgrepPath', () => {
vi.mocked(fileExists).mockImplementation(
async (checkPath) =>
checkPath.includes(path.normalize('core/vendor/ripgrep')) &&
!checkPath.includes(path.join(path.sep, 'tools', path.sep)),
!checkPath.includes('tools'),
);
const resolvedPath = await getRipgrepPath();
expect(resolvedPath).not.toBeNull();
expect(resolvedPath).toContain(path.normalize('core/vendor/ripgrep'));
expect(resolvedPath).not.toContain(
path.join(path.sep, 'tools', path.sep),
);
expect(resolvedPath).not.toContain('tools');
});
it('should return null if binary is missing from both paths', async () => {
+2 -9
View File
@@ -37,7 +37,6 @@ describe('Tracker Tools Integration', () => {
model: 'gemini-3-flash',
debugMode: false,
});
await config.initialize();
messageBus = new MessageBus(null as unknown as PolicyEngine, false);
});
@@ -121,14 +120,8 @@ describe('Tracker Tools Integration', () => {
);
const tasks = await config.getTrackerService().listTasks();
const parentTask = tasks.find((t) => t.title === 'Parent Task');
const childTask = tasks.find((t) => t.title === 'Child Task');
expect(parentTask).toBeDefined();
expect(childTask).toBeDefined();
const parentId = parentTask!.id;
const childId = childTask!.id;
const parentId = tasks.find((t) => t.title === 'Parent Task')!.id;
const childId = tasks.find((t) => t.title === 'Child Task')!.id;
// Add Dependency
const addDepTool = new TrackerAddDependencyTool(config, messageBus);
+1 -13
View File
@@ -240,15 +240,11 @@ foreach ($commandAst in $commandAsts) {
'utf16le',
).toString('base64');
export const REDIRECTION_NAMES = new Set([
const REDIRECTION_NAMES = new Set([
'redirection (<)',
'redirection (>)',
'heredoc (<<)',
'herestring (<<<)',
'command substitution',
'backtick substitution',
'process substitution',
'subshell',
]);
function createParser(): Parser | null {
@@ -364,14 +360,6 @@ function extractNameFromNode(node: Node): string | null {
return 'heredoc (<<)';
case 'herestring_redirect':
return 'herestring (<<<)';
case 'command_substitution':
return 'command substitution';
case 'backtick_substitution':
return 'backtick substitution';
case 'process_substitution':
return 'process substitution';
case 'subshell':
return 'subshell';
default:
return null;
}
+1 -1
View File
@@ -457,6 +457,7 @@ export class TestRig {
// Nightly releases sometimes becomes out of sync with local code and
// triggers auto-update, which causes tests to fail.
enableAutoUpdate: false,
enableAutoUpdateNotification: false,
},
telemetry: {
enabled: true,
@@ -680,7 +681,6 @@ export class TestRig {
key !== 'GEMINI_DEBUG' &&
key !== 'GEMINI_CLI_TEST_VAR' &&
key !== 'GEMINI_CLI_INTEGRATION_TEST' &&
key !== 'GOOGLE_GEMINI_BASE_URL' &&
!key.startsWith('GEMINI_CLI_ACTIVITY_LOG')
) {
delete cleanEnv[key];
File diff suppressed because one or more lines are too long
-51
View File
@@ -1,51 +0,0 @@
# Gemini CLI Bot (Cognitive Repository)
This directory contains the foundational architecture for the `gemini-cli-bot`,
transforming the repository into a proactive, evolutionary system.
It implements a dual-layer approach to balance immediate responsiveness with
long-term strategic optimization.
## Layered Execution Model
### 1. System 1: The Pulse (Reflex Layer)
- **Purpose**: High-frequency, deterministic maintenance and data collection.
- **Frequency**: 30-minute cron (`.github/workflows/gemini-cli-bot-pulse.yml`).
- **Implementation**: Pure TypeScript/JavaScript scripts.
- **Role**: Currently focuses on gathering repository metrics
(`tools/gemini-cli-bot/metrics/scripts`).
- **Output**: Action execution and `metrics-before.csv` artifact generation.
### 2. System 2: The Brain (Reasoning Layer)
- **Purpose**: Strategic investigation, policy refinement, and
self-optimization.
- **Frequency**: 24-hour cron (`.github/workflows/gemini-cli-bot-brain.yml`).
- **Implementation**: Agentic Gemini CLI phases.
- **Role**: Analyzing metric trends and running deeper repository health
investigations.
## Directory Structure
- `metrics/`: Contains the deterministic runner (`index.ts`) and individual
TypeScript scripts (`scripts/`) that use the GitHub CLI to track metrics like
open issues, PR latency, throughput, and reviewer domain expertise.
- `processes/scripts/`: Placeholder directory for future deterministic triage
and routing scripts.
- `investigations/`: Placeholder directory for agentic root-cause analysis
phases.
- `critique/`: Placeholder directory for policy evaluation.
- `history/`: Storage for downloaded metrics artifacts from previous runs.
## Usage
To manually collect repository metrics locally, run the following command from
the workspace root:
```bash
npm run metrics
```
This will execute all scripts within `metrics/scripts/` and output the results
to a `metrics-before.csv` file in the root directory.
-69
View File
@@ -1,69 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { execSync } from 'node:child_process';
const SCRIPTS_DIR = join(
process.cwd(),
'tools',
'gemini-cli-bot',
'metrics',
'scripts',
);
const OUTPUT_FILE = join(process.cwd(), 'metrics-before.csv');
function processOutputLine(line: string, results: string[]) {
const trimmedLine = line.trim();
if (!trimmedLine) return;
try {
const parsed = JSON.parse(trimmedLine);
if (
parsed &&
typeof parsed === 'object' &&
'metric' in parsed &&
'value' in parsed
) {
results.push(`${parsed.metric},${parsed.value}`);
} else {
results.push(trimmedLine);
}
} catch {
results.push(trimmedLine);
}
}
async function run() {
const scripts = readdirSync(SCRIPTS_DIR).filter(
(file) => file.endsWith('.ts') || file.endsWith('.js'),
);
const results: string[] = ['metric,value'];
for (const script of scripts) {
console.log(`Running metric script: ${script}`);
try {
const scriptPath = join(SCRIPTS_DIR, script);
const output = execSync(`npx tsx ${JSON.stringify(scriptPath)}`, {
encoding: 'utf-8',
});
const lines = output.trim().split('\n');
for (const line of lines) {
processOutputLine(line, results);
}
} catch (error) {
console.error(`Error running ${script}:`, error);
}
}
writeFileSync(OUTPUT_FILE, results.join('\n'));
console.log(`Saved metrics to ${OUTPUT_FILE}`);
}
run().catch(console.error);
@@ -1,157 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '../../../../');
try {
// 1. Fetch recent PR numbers and reviews from GitHub (so we have reviewer names/logins)
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100, states: MERGED) {
nodes {
number
reviews(first: 20) {
nodes {
authorAssociation
author { login, ... on User { name } }
}
}
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
);
const data = JSON.parse(output).data.repository;
// 2. Map PR numbers to local commits using git log
const logOutput = execSync('git log -n 5000 --format="%H|%s"', {
cwd: repoRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
const prCommits = new Map<number, string>();
for (const line of logOutput.split('\n')) {
if (!line) continue;
const [hash, subject] = line.split('|');
const match = subject.match(/\(#(\d+)\)$/);
if (match) {
prCommits.set(parseInt(match[1], 10), hash);
}
}
let totalMaintainerReviews = 0;
let maintainerReviewsWithExpertise = 0;
for (const pr of data.pullRequests.nodes) {
if (!pr.reviews?.nodes || pr.reviews.nodes.length === 0) continue;
const commitHash = prCommits.get(pr.number);
if (!commitHash) continue; // Skip if we don't have the commit locally
// 3. Get exact files changed using local git diff-tree, bypassing GraphQL limits
const diffTreeOutput = execSync(
`git diff-tree --no-commit-id --name-only -r ${commitHash}`,
{ cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
);
const files = diffTreeOutput.split('\n').filter(Boolean);
if (files.length === 0) continue;
// Cache git log authors per path to avoid redundant child_process calls
const authorCache = new Map<string, string>();
const getAuthors = (targetPath: string) => {
if (authorCache.has(targetPath)) return authorCache.get(targetPath)!;
try {
const authors = execSync(
`git log --format="%an|%ae" -- ${JSON.stringify(targetPath)}`,
{
cwd: repoRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
},
).toLowerCase();
authorCache.set(targetPath, authors);
return authors;
} catch {
authorCache.set(targetPath, '');
return '';
}
};
const reviewersOnPR = new Map<string, { name?: string }>();
for (const review of pr.reviews.nodes) {
if (
['MEMBER', 'OWNER'].includes(review.authorAssociation) &&
review.author?.login
) {
const login = review.author.login.toLowerCase();
if (login.endsWith('[bot]') || login.includes('bot')) continue;
reviewersOnPR.set(login, review.author);
}
}
for (const [login, authorInfo] of reviewersOnPR.entries()) {
totalMaintainerReviews++;
let hasExpertise = false;
const name = authorInfo.name ? authorInfo.name.toLowerCase() : '';
for (const file of files) {
// Precise check: immediate file
let authorsStr = getAuthors(file);
if (authorsStr.includes(login) || (name && authorsStr.includes(name))) {
hasExpertise = true;
break;
}
// Fallback: file's directory
const dir = path.dirname(file);
authorsStr = getAuthors(dir);
if (authorsStr.includes(login) || (name && authorsStr.includes(name))) {
hasExpertise = true;
break;
}
}
if (hasExpertise) {
maintainerReviewsWithExpertise++;
}
}
}
const ratio =
totalMaintainerReviews > 0
? maintainerReviewsWithExpertise / totalMaintainerReviews
: 0;
const timestamp = new Date().toISOString();
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'domain_expertise',
value: Math.round(ratio * 100) / 100,
timestamp,
details: {
totalMaintainerReviews,
maintainerReviewsWithExpertise,
},
}) + '\n',
);
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -1,138 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100, states: MERGED) {
nodes {
authorAssociation
createdAt
mergedAt
}
}
issues(last: 100, states: CLOSED) {
nodes {
authorAssociation
createdAt
closedAt
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const prs = data.pullRequests.nodes.map(
(p: {
authorAssociation: string;
mergedAt: string;
createdAt: string;
}) => ({
association: p.authorAssociation,
latencyHours:
(new Date(p.mergedAt).getTime() - new Date(p.createdAt).getTime()) /
(1000 * 60 * 60),
}),
);
const issues = data.issues.nodes.map(
(i: {
authorAssociation: string;
closedAt: string;
createdAt: string;
}) => ({
association: i.authorAssociation,
latencyHours:
(new Date(i.closedAt).getTime() - new Date(i.createdAt).getTime()) /
(1000 * 60 * 60),
}),
);
const isMaintainer = (assoc: string) =>
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
const calculateAvg = (
items: { association: string; latencyHours: number }[],
) =>
items.length
? items.reduce((a, b) => a + b.latencyHours, 0) / items.length
: 0;
const prMaintainers = calculateAvg(
prs.filter((i: { association: string; latencyHours: number }) =>
isMaintainer(i.association),
),
);
const prCommunity = calculateAvg(
prs.filter(
(i: { association: string; latencyHours: number }) =>
!isMaintainer(i.association),
),
);
const prOverall = calculateAvg(prs);
const issueMaintainers = calculateAvg(
issues.filter((i: { association: string; latencyHours: number }) =>
isMaintainer(i.association),
),
);
const issueCommunity = calculateAvg(
issues.filter(
(i: { association: string; latencyHours: number }) =>
!isMaintainer(i.association),
),
);
const issueOverall = calculateAvg(issues);
const timestamp = new Date().toISOString();
const metrics: MetricOutput[] = [
{
metric: 'latency_pr_overall_hours',
value: Math.round(prOverall * 100) / 100,
timestamp,
},
{
metric: 'latency_pr_maintainers_hours',
value: Math.round(prMaintainers * 100) / 100,
timestamp,
},
{
metric: 'latency_pr_community_hours',
value: Math.round(prCommunity * 100) / 100,
timestamp,
},
{
metric: 'latency_issue_overall_hours',
value: Math.round(issueOverall * 100) / 100,
timestamp,
},
{
metric: 'latency_issue_maintainers_hours',
value: Math.round(issueMaintainers * 100) / 100,
timestamp,
},
{
metric: 'latency_issue_community_hours',
value: Math.round(issueCommunity * 100) / 100,
timestamp,
},
];
metrics.forEach((m) => process.stdout.write(JSON.stringify(m) + '\n'));
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -1,20 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
try {
const count = execSync(
'gh issue list --state open --limit 1000 --json number --jq length',
{
encoding: 'utf-8',
},
).trim();
console.log(`open_issues,${count}`);
} catch {
// Fallback if gh fails or no issues found
console.log('open_issues,0');
}
@@ -1,20 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
try {
const count = execSync(
'gh pr list --state open --limit 1000 --json number --jq length',
{
encoding: 'utf-8',
},
).trim();
console.log(`open_prs,${count}`);
} catch {
// Fallback if gh fails or no PRs found
console.log('open_prs,0');
}
@@ -1,82 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100) {
nodes {
reviews(first: 50) {
nodes {
author { login }
authorAssociation
}
}
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const reviewCounts: Record<string, number> = {};
for (const pr of data.pullRequests.nodes) {
if (!pr.reviews?.nodes) continue;
// We only count one review per author per PR to avoid counting multiple review comments as multiple reviews
const reviewersOnPR = new Set<string>();
for (const review of pr.reviews.nodes) {
if (
['MEMBER', 'OWNER'].includes(review.authorAssociation) &&
review.author?.login
) {
const login = review.author.login.toLowerCase();
if (login.endsWith('[bot]') || login.includes('bot')) {
continue; // Ignore bots
}
reviewersOnPR.add(review.author.login);
}
}
for (const reviewer of reviewersOnPR) {
reviewCounts[reviewer] = (reviewCounts[reviewer] || 0) + 1;
}
}
const counts = Object.values(reviewCounts);
let variance = 0;
if (counts.length > 0) {
const mean = counts.reduce((a, b) => a + b, 0) / counts.length;
variance =
counts.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / counts.length;
}
const timestamp = new Date().toISOString();
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'review_distribution_variance',
value: Math.round(variance * 100) / 100,
timestamp,
details: reviewCounts,
}) + '\n',
);
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -1,148 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100, states: MERGED) {
nodes {
authorAssociation
mergedAt
}
}
issues(last: 100, states: CLOSED) {
nodes {
authorAssociation
closedAt
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const prs = data.pullRequests.nodes
.map((p: { authorAssociation: string; mergedAt: string }) => ({
association: p.authorAssociation,
date: new Date(p.mergedAt).getTime(),
}))
.sort((a: { date: number }, b: { date: number }) => a.date - b.date);
const issues = data.issues.nodes
.map((i: { authorAssociation: string; closedAt: string }) => ({
association: i.authorAssociation,
date: new Date(i.closedAt).getTime(),
}))
.sort((a: { date: number }, b: { date: number }) => a.date - b.date);
const isMaintainer = (assoc: string) =>
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
const calculateThroughput = (
items: { association: string; date: number }[],
) => {
if (items.length < 2) return 0;
const first = items[0].date;
const last = items[items.length - 1].date;
const days = (last - first) / (1000 * 60 * 60 * 24);
return days > 0 ? items.length / days : items.length; // items per day
};
const prOverall = calculateThroughput(prs);
const prMaintainers = calculateThroughput(
prs.filter((i: { association: string; date: number }) =>
isMaintainer(i.association),
),
);
const prCommunity = calculateThroughput(
prs.filter(
(i: { association: string; date: number }) =>
!isMaintainer(i.association),
),
);
const issueOverall = calculateThroughput(issues);
const issueMaintainers = calculateThroughput(
issues.filter((i: { association: string; date: number }) =>
isMaintainer(i.association),
),
);
const issueCommunity = calculateThroughput(
issues.filter(
(i: { association: string; date: number }) =>
!isMaintainer(i.association),
),
);
const timestamp = new Date().toISOString();
const metrics: MetricOutput[] = [
{
metric: 'throughput_pr_overall_per_day',
value: Math.round(prOverall * 100) / 100,
timestamp,
},
{
metric: 'throughput_pr_maintainers_per_day',
value: Math.round(prMaintainers * 100) / 100,
timestamp,
},
{
metric: 'throughput_pr_community_per_day',
value: Math.round(prCommunity * 100) / 100,
timestamp,
},
{
metric: 'throughput_issue_overall_per_day',
value: Math.round(issueOverall * 100) / 100,
timestamp,
},
{
metric: 'throughput_issue_maintainers_per_day',
value: Math.round(issueMaintainers * 100) / 100,
timestamp,
},
{
metric: 'throughput_issue_community_per_day',
value: Math.round(issueCommunity * 100) / 100,
timestamp,
},
{
metric: 'throughput_issue_overall_days_per_issue',
value: issueOverall > 0 ? Math.round((1 / issueOverall) * 100) / 100 : 0,
timestamp,
},
{
metric: 'throughput_issue_maintainers_days_per_issue',
value:
issueMaintainers > 0
? Math.round((1 / issueMaintainers) * 100) / 100
: 0,
timestamp,
},
{
metric: 'throughput_issue_community_days_per_issue',
value:
issueCommunity > 0 ? Math.round((1 / issueCommunity) * 100) / 100 : 0,
timestamp,
},
];
metrics.forEach((m) => process.stdout.write(JSON.stringify(m) + '\n'));
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -1,157 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100) {
nodes {
authorAssociation
author { login }
createdAt
comments(first: 20) {
nodes {
author { login }
createdAt
}
}
reviews(first: 20) {
nodes {
author { login }
createdAt
}
}
}
}
issues(last: 100) {
nodes {
authorAssociation
author { login }
createdAt
comments(first: 20) {
nodes {
author { login }
createdAt
}
}
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const getFirstResponseTime = (item: {
createdAt: string;
author: { login: string };
comments: { nodes: { createdAt: string; author?: { login: string } }[] };
reviews?: { nodes: { createdAt: string; author?: { login: string } }[] };
}) => {
const authorLogin = item.author?.login;
let earliestResponse: number | null = null;
const checkNodes = (
nodes: { createdAt: string; author?: { login: string } }[],
) => {
for (const node of nodes) {
if (node.author?.login && node.author.login !== authorLogin) {
const login = node.author.login.toLowerCase();
if (login.endsWith('[bot]') || login.includes('bot')) {
continue; // Ignore bots
}
const time = new Date(node.createdAt).getTime();
if (!earliestResponse || time < earliestResponse) {
earliestResponse = time;
}
}
}
};
if (item.comments?.nodes) checkNodes(item.comments.nodes);
if (item.reviews?.nodes) checkNodes(item.reviews.nodes);
if (earliestResponse) {
return (
(earliestResponse - new Date(item.createdAt).getTime()) /
(1000 * 60 * 60)
);
}
return null; // No response yet
};
const processItems = (
items: {
authorAssociation: string;
createdAt: string;
author: { login: string };
comments: {
nodes: { createdAt: string; author?: { login: string } }[];
};
reviews?: {
nodes: { createdAt: string; author?: { login: string } }[];
};
}[],
) => {
return items
.map((item) => ({
association: item.authorAssociation,
ttfr: getFirstResponseTime(item),
}))
.filter((i) => i.ttfr !== null) as {
association: string;
ttfr: number;
}[];
};
const prs = processItems(data.pullRequests.nodes);
const issues = processItems(data.issues.nodes);
const allItems = [...prs, ...issues];
const isMaintainer = (assoc: string) => ['MEMBER', 'OWNER'].includes(assoc);
const is1P = (assoc: string) => ['COLLABORATOR'].includes(assoc);
const calculateAvg = (items: { ttfr: number; association: string }[]) =>
items.length ? items.reduce((a, b) => a + b.ttfr, 0) / items.length : 0;
const maintainers = calculateAvg(
allItems.filter((i) => isMaintainer(i.association)),
);
const firstParty = calculateAvg(allItems.filter((i) => is1P(i.association)));
const overall = calculateAvg(allItems);
const timestamp = new Date().toISOString();
const metrics: MetricOutput[] = [
{
metric: 'time_to_first_response_overall_hours',
value: Math.round(overall * 100) / 100,
timestamp,
},
{
metric: 'time_to_first_response_maintainers_hours',
value: Math.round(maintainers * 100) / 100,
timestamp,
},
{
metric: 'time_to_first_response_1p_hours',
value: Math.round(firstParty * 100) / 100,
timestamp,
},
];
metrics.forEach((m) => process.stdout.write(JSON.stringify(m) + '\n'));
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
@@ -1,100 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*
* @license
*/
import { GITHUB_OWNER, GITHUB_REPO, type MetricOutput } from '../types.js';
import { execSync } from 'node:child_process';
try {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
pullRequests(last: 100, states: MERGED) {
nodes {
authorAssociation
comments { totalCount }
reviews { totalCount }
}
}
issues(last: 100, states: CLOSED) {
nodes {
authorAssociation
comments { totalCount }
}
}
}
}
`;
const output = execSync(
`gh api graphql -F owner=${GITHUB_OWNER} -F repo=${GITHUB_REPO} -f query='${query}'`,
{ encoding: 'utf-8' },
);
const data = JSON.parse(output).data.repository;
const prs = data.pullRequests.nodes;
const issues = data.issues.nodes;
const allItems = [
...prs.map(
(p: {
authorAssociation: string;
comments: { totalCount: number };
reviews?: { totalCount: number };
}) => ({
association: p.authorAssociation,
touches: p.comments.totalCount + (p.reviews ? p.reviews.totalCount : 0),
}),
),
...issues.map(
(i: { authorAssociation: string; comments: { totalCount: number } }) => ({
association: i.authorAssociation,
touches: i.comments.totalCount,
}),
),
];
const isMaintainer = (assoc: string) =>
['MEMBER', 'OWNER', 'COLLABORATOR'].includes(assoc);
const calculateAvg = (items: { touches: number; association: string }[]) =>
items.length ? items.reduce((a, b) => a + b.touches, 0) / items.length : 0;
const overall = calculateAvg(allItems);
const maintainers = calculateAvg(
allItems.filter((i) => isMaintainer(i.association)),
);
const community = calculateAvg(
allItems.filter((i) => !isMaintainer(i.association)),
);
const timestamp = new Date().toISOString();
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'user_touches_overall',
value: Math.round(overall * 100) / 100,
timestamp,
}) + '\n',
);
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'user_touches_maintainers',
value: Math.round(maintainers * 100) / 100,
timestamp,
}) + '\n',
);
process.stdout.write(
JSON.stringify(<MetricOutput>{
metric: 'user_touches_community',
value: Math.round(community * 100) / 100,
timestamp,
}) + '\n',
);
} catch (err) {
process.stderr.write(err instanceof Error ? err.message : String(err));
process.exit(1);
}
-14
View File
@@ -1,14 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface MetricOutput {
metric: string;
value: number | string;
timestamp: string;
details?: Record<string, unknown>;
}
export const GITHUB_OWNER = 'google-gemini';
export const GITHUB_REPO = 'gemini-cli';