mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -07:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dde5ac077 | |||
| 7191d71711 | |||
| 0c4ac593eb | |||
| 8572e60f9c | |||
| daba5229ec | |||
| ec786aeaa8 | |||
| 76c97bfcc0 | |||
| 128bba380a | |||
| cd8bfce192 | |||
| 8f0edcd64f | |||
| 04e875c5c8 | |||
| a79da4f3a9 | |||
| 56809d7069 | |||
| 5dfbb739e5 | |||
| f87072f4e3 | |||
| 6a3175e973 | |||
| 4d1ca92a19 | |||
| b6fc583b0c | |||
| 0d6bd29752 | |||
| 78877942ec | |||
| 493b555646 | |||
| 75a8de83fc | |||
| a7beb890d0 | |||
| 60a6a47d56 | |||
| d313cd7dde | |||
| 77f4be1f3d | |||
| 0da1a2026a | |||
| 37edd1d4df | |||
| 165efa8a38 | |||
| 790f2cf815 | |||
| 704be5a418 | |||
| 9de8c8aadb | |||
| 0657d315fb | |||
| 88bdadc9c6 | |||
| 30c324dec7 | |||
| 40aa7397b6 | |||
| ab48aad213 | |||
| 4fa2c95c59 | |||
| 4e175527a2 | |||
| 1b021bddab | |||
| 40b384de2c | |||
| de8fdcfa16 | |||
| 39de9586a0 | |||
| 393d72ac52 | |||
| 408afd3c5a | |||
| c18ae0c382 | |||
| a93d2a1d1c | |||
| 381aae25b2 | |||
| dc5b3114c0 | |||
| 9380e13f6d | |||
| 363854172f | |||
| 7dea5b47a1 | |||
| 997f461cad | |||
| b14a29efa2 | |||
| f496354884 | |||
| 76d1a73606 | |||
| b266912e61 | |||
| 8fb1b5aa01 | |||
| 9cb48020e1 | |||
| 8943640a71 | |||
| 7213822e84 | |||
| d9f273e440 | |||
| c6121d5113 | |||
| b3e6c28933 | |||
| 4e81f48646 | |||
| 2e3090b6d9 | |||
| c427bd442f | |||
| caa0466416 | |||
| f497240f7e | |||
| 892c8a720d | |||
| 80e3bb9689 | |||
| d494195602 | |||
| 2f0c7518ad | |||
| a03ec92436 | |||
| e67631132d | |||
| 778da08ee1 | |||
| 7125d2cd65 | |||
| 84616626f5 | |||
| ef040eb392 | |||
| 8c1e255ac0 | |||
| 0af13141b2 | |||
| 90895efb29 | |||
| 84875ce911 | |||
| c94edcd862 | |||
| 0f1077076e | |||
| 9a98b0e56c | |||
| 071e2923bb | |||
| 487fb219cc | |||
| d743c6fae6 | |||
| a15568e013 | |||
| 0ccc5ce58f | |||
| 1834ad0298 | |||
| a2d10b7b99 | |||
| 8cec567064 | |||
| fa1a7c10bd | |||
| 49988fc05c | |||
| d6ce310901 | |||
| 2194da2b02 | |||
| dce13019b9 | |||
| 88626f37e3 | |||
| 3aedbbc067 | |||
| 99235fc59d | |||
| 25f422d0e4 | |||
| 6dec6720de | |||
| 3bc56d0ef5 | |||
| 011c0f9bc0 | |||
| 2cf0c75a04 | |||
| 7ab932c8bf | |||
| c2e5b28e94 | |||
| c7d5fcff95 | |||
| 6d99113936 | |||
| fbd8aaad57 | |||
| 9e7c924f7b | |||
| 4edd7c745c | |||
| 12a77da45c | |||
| 8cfebb9e31 | |||
| f8603e990b | |||
| 59b2dea0e5 | |||
| c841070582 | |||
| 4b8d5e7624 | |||
| 7a3f7c383e | |||
| 8e1cecac06 | |||
| b0ffa3b51e | |||
| 58a57b72ae | |||
| 54b7586106 | |||
| c17400b830 | |||
| 47bca39eeb | |||
| 07506dcd0d | |||
| 6cc0b1b136 | |||
| 820a4e3c92 | |||
| 7d08f84305 | |||
| 31337b9269 | |||
| b1a50a58af | |||
| 71f313b51a | |||
| 98aca28985 | |||
| 2de81902c3 | |||
| 1cdfeb6633 | |||
| 743518e1b8 | |||
| 42587de733 | |||
| a5b030b424 | |||
| 2e0641c83b | |||
| 048bf6e514 | |||
| ed469e492b | |||
| cfd7541ad4 |
@@ -0,0 +1,17 @@
|
||||
# Git history - not needed in build context
|
||||
.git
|
||||
|
||||
# Root node_modules - reinstalled inside container via npm ci
|
||||
node_modules
|
||||
|
||||
# Package-level node_modules - reinstalled inside container
|
||||
packages/*/node_modules
|
||||
|
||||
# Development and IDE files
|
||||
.github
|
||||
.vscode
|
||||
npm-debug.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.tmp
|
||||
@@ -53,11 +53,27 @@ Gemini CLI project.
|
||||
overriding values. Refer to `text-buffer.ts` for a canonical example.
|
||||
- **Logging**: Do not leave `console.log`, `console.warn`, or `console.error` in
|
||||
the code.
|
||||
- **State & Effects**: Ensure state initialization is explicit (e.g., use
|
||||
`undefined` rather than `true` as a default if the state is truly unknown).
|
||||
Carefully manage `useEffect` dependencies. Prefer a reducer whenever
|
||||
practical. NEVER disable `react-hooks/exhaustive-deps`; fix the code to
|
||||
correctly declare dependencies instead.
|
||||
- **State**: Ensure state initialization is explicit (e.g., use `undefined`
|
||||
rather than `true` as a default if the state is truly unknown). Prefer a
|
||||
reducer whenever practical. NEVER disable `react-hooks/exhaustive-deps`; fix
|
||||
the code to correctly declare dependencies instead. Evaluate all the React
|
||||
states in a component and ensure that the `useState` calls are necessary and
|
||||
not cases where values could be derived on render. Ensure there are no stale
|
||||
closures that are relying on a value from a previous render. React Components
|
||||
that modify Settings should effectively use the `useSettingsStore` pattern.
|
||||
Components that configure application Settings (e.g settings.json) are the
|
||||
only reasonable case for unsaved changes to drive UX; in these cases, the
|
||||
Settings store should only be written to on save. If the user experience does
|
||||
not utilize unsaved changes because there is no option to exit without saving
|
||||
or reverting the unsaved changes, then the component should directly read from
|
||||
and write to the Settings store without holding pending changes in component
|
||||
level UI state.
|
||||
- **Effect**: `useEffect` should not be used to synchronize React states, it
|
||||
should only be used for genuine side effects that occur outside of React.
|
||||
Contributors should be able to strongly justify the need for an effect.
|
||||
Consider whether the effect should instead be inside an event handler, or
|
||||
whether it is better off being computed on render. Carefully manage
|
||||
`useEffect` dependencies.
|
||||
- **Context & Props**: Avoid excessive property drilling. Leverage existing
|
||||
providers, extend them, or propose a new one if necessary. Only use providers
|
||||
for properties that are consistent across the entire application.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"gemma": true
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gemini Scheduled Lifecycle Manager Script
|
||||
* @param {object} param0
|
||||
* @param {import('@octokit/rest').Octokit} param0.github
|
||||
* @param {import('@actions/github/lib/context').Context} param0.context
|
||||
* @param {import('@actions/core')} param0.core
|
||||
*/
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const STALE_LABEL = 'stale';
|
||||
const NEED_INFO_LABEL = 'status/need-information';
|
||||
const EXEMPT_LABELS = [
|
||||
'pinned',
|
||||
'security',
|
||||
'🔒 maintainer only',
|
||||
'help wanted',
|
||||
'🗓️ Public Roadmap',
|
||||
];
|
||||
|
||||
const STALE_DAYS = 30;
|
||||
const CLOSE_DAYS = 7;
|
||||
const NO_RESPONSE_DAYS = 7;
|
||||
|
||||
const now = new Date();
|
||||
const staleThreshold = new Date(
|
||||
now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const closeThreshold = new Date(
|
||||
now.getTime() - CLOSE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const noResponseThreshold = new Date(
|
||||
now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
async function processItems(query, callback) {
|
||||
core.info(`Searching: ${query}`);
|
||||
try {
|
||||
const response = await github.rest.search.issuesAndPullRequests({
|
||||
q: query,
|
||||
per_page: 100,
|
||||
sort: 'updated',
|
||||
order: 'asc',
|
||||
});
|
||||
const items = response.data.items;
|
||||
core.info(`Found ${items.length} items (batch limited).`);
|
||||
for (const item of items) {
|
||||
try {
|
||||
await callback(item);
|
||||
} catch (err) {
|
||||
core.error(`Error processing #${item.number}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
core.error(`Search failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Handle No-Response (status/need-information)
|
||||
// Removal: Check issues updated in the last 48h that have the label
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:>${twoDaysAgo.toISOString()}`,
|
||||
async (item) => {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
sort: 'created',
|
||||
direction: 'desc',
|
||||
per_page: 5,
|
||||
});
|
||||
|
||||
// Check if the last comment is from a non-maintainer
|
||||
const lastComment = comments[0];
|
||||
if (
|
||||
lastComment &&
|
||||
!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
|
||||
lastComment.author_association,
|
||||
) &&
|
||||
lastComment.user?.type !== 'Bot'
|
||||
) {
|
||||
core.info(
|
||||
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues
|
||||
.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
name: NEED_INFO_LABEL,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Closure: Check issues with the label that haven't been updated in 14 days
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(
|
||||
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item was marked as needing more information and has not received a response in ${NO_RESPONSE_DAYS} days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Handle Stale Mark (60 days inactivity, no stale label)
|
||||
const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' ');
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(`Marking #${item.number} as stale.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
labels: [STALE_LABEL],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Handle Stale Close (14 days with stale label)
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(`Closing stale item #${item.number}.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item has been closed due to ${CLOSE_DAYS} additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen. Thank you!`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 4. Handle PR Contribution Policy (Nudge at 7d, Close at 14d)
|
||||
const PR_NUDGE_DAYS = 7;
|
||||
const PR_CLOSE_DAYS = 14;
|
||||
const nudgeThreshold = new Date(
|
||||
now.getTime() - PR_NUDGE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const prCloseThreshold = new Date(
|
||||
now.getTime() - PR_CLOSE_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Nudge
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`,
|
||||
async (pr) => {
|
||||
if (
|
||||
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
|
||||
pr.user?.type === 'Bot'
|
||||
)
|
||||
return;
|
||||
|
||||
core.info(`Nudging PR #${pr.number} for contribution policy.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: ['status/pr-nudge-sent'],
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'. \n\nThis PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Close
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" created:<${prCloseThreshold.toISOString()}`,
|
||||
async (pr) => {
|
||||
if (
|
||||
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
|
||||
pr.user?.type === 'Bot'
|
||||
)
|
||||
return;
|
||||
|
||||
core.info(
|
||||
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: "This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.",
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
name: 'Build Unsigned Mac Binaries'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
build-mac:
|
||||
name: 'Build Unsigned (${{ matrix.arch }})'
|
||||
runs-on: 'macos-latest'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ['x64', 'arm64']
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
architecture: '${{ matrix.arch }}'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build Binary'
|
||||
env:
|
||||
SKIP_SIGNING: 'true'
|
||||
run: 'npm run build:binary'
|
||||
|
||||
- name: 'Verify Output Exists'
|
||||
run: |
|
||||
if [ -f "dist/darwin-${{ matrix.arch }}/gemini" ]; then
|
||||
echo "Binary found at dist/darwin-${{ matrix.arch }}/gemini"
|
||||
else
|
||||
echo "Error: Binary not found in dist/darwin-${{ matrix.arch }}/"
|
||||
ls -R dist/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 'Upload Artifact'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'gemini-darwin-${{ matrix.arch }}-unsigned'
|
||||
path: 'dist/darwin-${{ matrix.arch }}/'
|
||||
retention-days: 5
|
||||
@@ -337,6 +337,7 @@ jobs:
|
||||
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
GEMINI_MODEL: 'gemini-3-pro-preview'
|
||||
# Only run always passes behavioral tests.
|
||||
EVAL_SUITE_TYPE: 'behavioral'
|
||||
|
||||
@@ -147,10 +147,7 @@ jobs:
|
||||
pull-requests: 'write'
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- '20.x'
|
||||
- '22.x'
|
||||
- '24.x'
|
||||
node-version: ${{ fromJSON(github.event_name == 'pull_request' && '["20.x"]' || '["20.x", "22.x", "24.x"]') }}
|
||||
shard:
|
||||
- 'cli'
|
||||
- 'others'
|
||||
@@ -242,10 +239,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
node-version:
|
||||
- '20.x'
|
||||
- '22.x'
|
||||
- '24.x'
|
||||
node-version: ["20.x"]
|
||||
shard:
|
||||
- 'cli'
|
||||
- 'others'
|
||||
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
- name: 'Run Docs Audit with Gemini'
|
||||
id: 'run_gemini'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31'
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
prompt: |
|
||||
|
||||
@@ -66,6 +66,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: 'true'
|
||||
EVAL_SUITE_TYPE: "${{ github.event.inputs.suite_type || 'behavioral' }}"
|
||||
|
||||
@@ -3,27 +3,77 @@ name: '🧠 Gemini CLI Bot: Brain'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Every 24 hours
|
||||
issue_comment:
|
||||
types: ['created']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_interactive:
|
||||
description: 'Run interactive flow (requires issue_number)'
|
||||
type: 'boolean'
|
||||
default: false
|
||||
issue_number:
|
||||
description: 'Issue/PR number to simulate context from'
|
||||
type: 'string'
|
||||
required: false
|
||||
comment_id:
|
||||
description: 'Specific comment ID to simulate'
|
||||
type: 'string'
|
||||
required: false
|
||||
clear_memory:
|
||||
description: 'Clear memory (drops learnings from previous runs)'
|
||||
type: 'boolean'
|
||||
default: false
|
||||
enable_prs:
|
||||
description: 'Enable PRs (automatically promote changes to PRs)'
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number || github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
brain:
|
||||
reasoning:
|
||||
name: 'Brain (Reasoning Layer)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
if: |
|
||||
github.repository == 'google-gemini/gemini-cli' && (
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive != 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive == 'true') ||
|
||||
(github.event_name == 'issue_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association)) ||
|
||||
(github.event_name == 'pull_request_review_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association))
|
||||
)
|
||||
# The reasoning phase is strictly readonly.
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'read'
|
||||
pull-requests: 'read'
|
||||
actions: 'read'
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
steps:
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
|
||||
run: |
|
||||
REF="${{ github.ref }}"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
|
||||
if [ -n "$PR_HEAD" ]; then
|
||||
REF="$PR_HEAD"
|
||||
fi
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ steps.determine_ref.outputs.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
@@ -37,9 +87,296 @@ jobs:
|
||||
- name: 'Build Gemini CLI'
|
||||
run: 'npm run bundle'
|
||||
|
||||
- name: 'Download Previous Metrics'
|
||||
- name: 'Download Previous State'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.clear_memory }}" = "true" ]; then
|
||||
echo "Memory clear requested. Skipping previous state download."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the last successful run of this workflow
|
||||
LAST_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" --status success --limit 1 --json databaseId --jq '.[0].databaseId')
|
||||
|
||||
if [ -n "$LAST_RUN_ID" ]; then
|
||||
echo "Found previous successful run: $LAST_RUN_ID"
|
||||
|
||||
# Download brain memory to a temp dir so we can selectively restore only persistent state
|
||||
mkdir -p .temp_brain_data
|
||||
gh run download "$LAST_RUN_ID" -n brain-data -D .temp_brain_data || echo "brain-data not found"
|
||||
|
||||
# Restore only persistent memory files
|
||||
cp .temp_brain_data/tools/gemini-cli-bot/lessons-learned.md tools/gemini-cli-bot/lessons-learned.md 2>/dev/null || true
|
||||
mkdir -p tools/gemini-cli-bot/history/
|
||||
cp .temp_brain_data/tools/gemini-cli-bot/history/*.csv tools/gemini-cli-bot/history/ 2>/dev/null || true
|
||||
rm -rf .temp_brain_data
|
||||
else
|
||||
echo "No previous successful run found."
|
||||
fi
|
||||
|
||||
- name: 'Collect Current Metrics'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: 'npx tsx tools/gemini-cli-bot/metrics/index.ts'
|
||||
|
||||
- name: 'Run Brain and Critique Loop'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'false' }}"
|
||||
TRIGGER_ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}'
|
||||
TRIGGER_COMMENT_ID: '${{ github.event.comment.id || github.event.inputs.comment_id }}'
|
||||
run: |
|
||||
PROMPT_PATH="tools/gemini-cli-bot/brain/metrics.md"
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ] || [ "${{ github.event.inputs.run_interactive }}" = "true" ]; then
|
||||
PROMPT_PATH="tools/gemini-cli-bot/brain/interactive.md"
|
||||
export ENABLE_PRS="true"
|
||||
fi
|
||||
|
||||
touch trigger_context.md
|
||||
if [ -n "$TRIGGER_ISSUE_NUMBER" ]; then
|
||||
echo "<untrusted_context>" > trigger_context.md
|
||||
echo "# Interactive Trigger Context" >> trigger_context.md
|
||||
echo "You were invoked by a user in issue/PR #$TRIGGER_ISSUE_NUMBER." >> trigger_context.md
|
||||
|
||||
if [ -n "$TRIGGER_COMMENT_ID" ]; then
|
||||
echo "## User Comment" >> trigger_context.md
|
||||
gh api "repos/${{ github.repository }}/issues/comments/$TRIGGER_COMMENT_ID" -q '.body' >> trigger_context.md 2>/dev/null || gh api "repos/${{ github.repository }}/pulls/comments/$TRIGGER_COMMENT_ID" -q '.body' >> trigger_context.md
|
||||
echo "" >> trigger_context.md
|
||||
fi
|
||||
|
||||
echo "## Issue/PR Context" >> trigger_context.md
|
||||
gh issue view "$TRIGGER_ISSUE_NUMBER" >> trigger_context.md 2>/dev/null || gh pr view "$TRIGGER_ISSUE_NUMBER" >> trigger_context.md
|
||||
echo "</untrusted_context>" >> trigger_context.md
|
||||
fi
|
||||
|
||||
MAX_ITERATIONS=4
|
||||
ITERATION=1
|
||||
|
||||
while [ $ITERATION -le $MAX_ITERATIONS ]; do
|
||||
echo "========================================"
|
||||
echo "Starting Iteration $ITERATION"
|
||||
echo "========================================"
|
||||
|
||||
# --- BRAIN PHASE ---
|
||||
cat trigger_context.md > combined_prompt.md
|
||||
if [ -f "critique_feedback.md" ]; then
|
||||
cat critique_feedback.md >> combined_prompt.md
|
||||
fi
|
||||
cat "$PROMPT_PATH" tools/gemini-cli-bot/brain/common.md >> combined_prompt.md
|
||||
|
||||
echo "Running Brain Agent..."
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat combined_prompt.md)"
|
||||
|
||||
if [ -n "$TRIGGER_ISSUE_NUMBER" ] && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
|
||||
echo "Agent failed to respond. Generating fallback error message."
|
||||
echo "⚠️ **Gemini CLI Bot failed to generate a response.**" > "issue-comment.md"
|
||||
echo "" >> "issue-comment.md"
|
||||
echo "I encountered an error or failed to generate a complete response to your request. You can check the [GitHub Actions Run Log](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details on what went wrong." >> "issue-comment.md"
|
||||
fi
|
||||
|
||||
# --- CRITIQUE PHASE ---
|
||||
if [ "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}" != "true" ]; then
|
||||
echo "PRs disabled, skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
break
|
||||
fi
|
||||
|
||||
if git diff --staged --quiet && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
|
||||
echo "No changes staged and no comments generated. Skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Running Critique Agent..."
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
|
||||
|
||||
if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then
|
||||
echo "Critique Approved."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
break
|
||||
else
|
||||
echo "Critique Rejected."
|
||||
if [ $ITERATION -lt $MAX_ITERATIONS ]; then
|
||||
echo "Preparing feedback for next iteration..."
|
||||
echo "<critique_feedback>" > critique_feedback.md
|
||||
echo "# Critique Feedback (Iteration $ITERATION)" >> critique_feedback.md
|
||||
echo "Your previous changes were rejected by the Critique agent. You MUST fix the following issues:" >> critique_feedback.md
|
||||
cat critique_output.log >> critique_feedback.md
|
||||
echo "</critique_feedback>" >> critique_feedback.md
|
||||
|
||||
# Discard rejected changes
|
||||
git reset
|
||||
git checkout .
|
||||
rm -f pr-description.md branch-name.txt pr-comment.md pr-number.txt issue-comment.md bot-changes.patch rejected-changes.patch
|
||||
else
|
||||
echo "Max iterations reached. Failing."
|
||||
echo "[REJECTED]" > critique_result.txt
|
||||
# We still want to upload artifacts for debugging even if it failed.
|
||||
git diff --staged > rejected-changes.patch || true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
|
||||
ITERATION=$((ITERATION+1))
|
||||
done
|
||||
- name: 'Generate Patch'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
run: |
|
||||
touch bot-changes.patch
|
||||
touch pr-description.md
|
||||
if [ -f critique_result.txt ] && grep -q "\[APPROVED\]" critique_result.txt && ! grep -q "\[REJECTED\]" critique_result.txt; then
|
||||
git diff --staged > bot-changes.patch
|
||||
else
|
||||
echo "Critique did not approve. Skipping patch generation."
|
||||
fi
|
||||
|
||||
- name: 'Archive Brain Data'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'brain-data'
|
||||
path: |
|
||||
tools/gemini-cli-bot/lessons-learned.md
|
||||
tools/gemini-cli-bot/history/*.csv
|
||||
bot-changes.patch
|
||||
rejected-changes.patch
|
||||
pr-description.md
|
||||
branch-name.txt
|
||||
pr-comment.md
|
||||
pr-number.txt
|
||||
issue-comment.md
|
||||
retention-days: 90
|
||||
|
||||
publish:
|
||||
name: 'Publish Artifacts (Archive Layer)'
|
||||
needs: 'reasoning'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
# The publish phase is for archiving artifacts and optionally creating PRs.
|
||||
permissions:
|
||||
contents: 'write'
|
||||
pull-requests: 'write'
|
||||
actions: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token 🔑'
|
||||
id: 'generate_token'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
owner: '${{ github.repository_owner }}'
|
||||
repositories: '${{ github.event.repository.name }}'
|
||||
permission-contents: 'write'
|
||||
permission-pull-requests: 'write'
|
||||
permission-issues: 'write'
|
||||
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
|
||||
run: |
|
||||
REF="${{ github.ref }}"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
|
||||
if [ -n "$PR_HEAD" ]; then
|
||||
REF="$PR_HEAD"
|
||||
fi
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ steps.determine_ref.outputs.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Download Brain Data'
|
||||
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # ratchet:actions/download-artifact@v4
|
||||
with:
|
||||
name: 'metrics-before'
|
||||
path: 'tools/gemini-cli-bot/history/'
|
||||
continue-on-error: true
|
||||
name: 'brain-data'
|
||||
path: '${{ runner.temp }}/brain-data/'
|
||||
|
||||
- name: 'Create or Update PR'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
env:
|
||||
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
FALLBACK_PAT: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
run: |
|
||||
if [ -s "${{ runner.temp }}/brain-data/bot-changes.patch" ]; then
|
||||
git config user.name "gemini-cli[bot]"
|
||||
git config user.email "gemini-cli[bot]@users.noreply.github.com"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
|
||||
BRANCH_NAME="bot/productivity-updates-$(date +'%Y%m%d%H%M%S')-${{ github.run_id }}"
|
||||
if [ -f "${{ runner.temp }}/brain-data/branch-name.txt" ]; then
|
||||
BRANCH_NAME=$(cat "${{ runner.temp }}/brain-data/branch-name.txt")
|
||||
fi
|
||||
|
||||
if [[ ! "$BRANCH_NAME" =~ ^bot/ ]]; then
|
||||
echo "Error: Branch name '$BRANCH_NAME' does not start with 'bot/'. Safety abort."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git checkout -B "$BRANCH_NAME"
|
||||
git apply "${{ runner.temp }}/brain-data/bot-changes.patch"
|
||||
git add .
|
||||
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
|
||||
git commit -F "${{ runner.temp }}/brain-data/pr-description.md"
|
||||
else
|
||||
git commit -m "🤖 Gemini Bot Productivity Optimizations"
|
||||
fi
|
||||
|
||||
PR_TITLE="🤖 Gemini Bot Productivity Optimizations"
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
|
||||
PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md")
|
||||
fi
|
||||
|
||||
if ! git push origin "$BRANCH_NAME" --force; then
|
||||
echo "Push failed. Retrying with FALLBACK_PAT..."
|
||||
export GH_TOKEN="$FALLBACK_PAT"
|
||||
git remote set-url origin "https://x-access-token:${FALLBACK_PAT}@github.com/${{ github.repository }}.git"
|
||||
git push origin "$BRANCH_NAME" --force
|
||||
fi
|
||||
|
||||
if ! gh pr view "$BRANCH_NAME" > /dev/null 2>&1; then
|
||||
gh pr create --draft --title "$PR_TITLE" --body-file "${{ runner.temp }}/brain-data/pr-description.md" --head "$BRANCH_NAME" --base main || \
|
||||
gh pr create --draft --title "🤖 Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$BRANCH_NAME" --base main
|
||||
else
|
||||
PR_STATE=$(gh pr view "$BRANCH_NAME" --json state --jq .state)
|
||||
if [ "$PR_STATE" = "CLOSED" ]; then
|
||||
NEW_BRANCH_NAME="${BRANCH_NAME}-retry-${{ github.run_id }}"
|
||||
git checkout -b "$NEW_BRANCH_NAME"
|
||||
git push origin "$NEW_BRANCH_NAME" --force
|
||||
gh pr create --draft --title "$PR_TITLE" --body-file "${{ runner.temp }}/brain-data/pr-description.md" --head "$NEW_BRANCH_NAME" --base main || \
|
||||
gh pr create --draft --title "🤖 Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$NEW_BRANCH_NAME" --base main
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: 'Post PR/Issue Comment'
|
||||
env:
|
||||
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
TRIGGER_ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}'
|
||||
run: |
|
||||
if [ -s "${{ runner.temp }}/brain-data/issue-comment.md" ] && [ -n "$TRIGGER_ISSUE_NUMBER" ]; then
|
||||
echo "Posting comment to triggering issue #$TRIGGER_ISSUE_NUMBER"
|
||||
# Use REST API (gh api) instead of GraphQL (gh issue comment) to ensure robot identity
|
||||
# while avoiding potential GraphQL-specific authorization hurdles with PATs.
|
||||
gh api "repos/${{ github.repository }}/issues/$TRIGGER_ISSUE_NUMBER/comments" -F body=@"${{ runner.temp }}/brain-data/issue-comment.md"
|
||||
fi
|
||||
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-comment.md" ] && [ -f "${{ runner.temp }}/brain-data/pr-number.txt" ]; then
|
||||
PR_NUM=$(cat "${{ runner.temp }}/brain-data/pr-number.txt")
|
||||
|
||||
# Using GitHub App, so author check is no longer valid against gemini-cli-robot
|
||||
# Skipping author validation here to let the app post.
|
||||
|
||||
# Use REST API (gh api) for consistency and robot identity
|
||||
gh api "repos/${{ github.repository }}/issues/$PR_NUM/comments" -F body=@"${{ runner.temp }}/brain-data/pr-comment.md"
|
||||
fi
|
||||
|
||||
@@ -34,23 +34,12 @@ jobs:
|
||||
- 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
|
||||
if [ -d "tools/gemini-cli-bot/reflexes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/reflexes/scripts)" ]; then
|
||||
for script in tools/gemini-cli-bot/reflexes/scripts/*.ts; do
|
||||
echo "Running reflex script: $script"
|
||||
npx tsx "$script"
|
||||
done
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: '🔄 Gemini Scheduled Lifecycle Manager'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *' # Once a day
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode (no changes applied)'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
manage-lifecycle:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
|
||||
|
||||
- name: 'Lifecycle Management'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |
|
||||
const script = require('./.github/scripts/gemini-lifecycle-manager.cjs');
|
||||
await script({github, context, core});
|
||||
@@ -63,15 +63,15 @@ jobs:
|
||||
|
||||
echo '🔍 Finding issues missing area labels...'
|
||||
NO_AREA_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🔍 Finding issues missing kind labels...'
|
||||
NO_KIND_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🏷️ Finding issues missing priority labels...'
|
||||
NO_PRIORITY_ISSUES="$(gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body)"
|
||||
|
||||
echo '🔄 Merging and deduplicating issues...'
|
||||
ISSUES="$(echo "${NO_AREA_ISSUES}" "${NO_KIND_ISSUES}" "${NO_PRIORITY_ISSUES}" | jq -c -s 'add | unique_by(.number)')"
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
name: '🔒 Gemini Scheduled Stale Issue Closer'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode (no changes applied)'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: 'bash'
|
||||
|
||||
jobs:
|
||||
close-stale-issues:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
permission-issues: 'write'
|
||||
|
||||
- name: 'Process Stale Issues'
|
||||
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
if (dryRun) {
|
||||
core.info('DRY RUN MODE ENABLED: No changes will be applied.');
|
||||
}
|
||||
const batchLabel = 'Stale';
|
||||
|
||||
const threeMonthsAgo = new Date();
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
|
||||
const tenDaysAgo = new Date();
|
||||
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
|
||||
|
||||
core.info(`Cutoff date for creation: ${threeMonthsAgo.toISOString()}`);
|
||||
core.info(`Cutoff date for updates: ${tenDaysAgo.toISOString()}`);
|
||||
|
||||
const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open created:<${threeMonthsAgo.toISOString()}`;
|
||||
core.info(`Searching with query: ${query}`);
|
||||
|
||||
const itemsToCheck = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
sort: 'created',
|
||||
order: 'asc',
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
core.info(`Found ${itemsToCheck.length} open issues to check.`);
|
||||
|
||||
let processedCount = 0;
|
||||
|
||||
for (const issue of itemsToCheck) {
|
||||
const createdAt = new Date(issue.created_at);
|
||||
const updatedAt = new Date(issue.updated_at);
|
||||
const reactionCount = issue.reactions.total_count;
|
||||
|
||||
// Basic thresholds
|
||||
if (reactionCount >= 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if it has a maintainer, help wanted, or Public Roadmap label
|
||||
const rawLabels = issue.labels.map((l) => l.name);
|
||||
const lowercaseLabels = rawLabels.map((l) => l.toLowerCase());
|
||||
if (
|
||||
lowercaseLabels.some((l) => l.includes('maintainer')) ||
|
||||
lowercaseLabels.includes('help wanted') ||
|
||||
rawLabels.includes('🗓️ Public Roadmap')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let isStale = updatedAt < tenDaysAgo;
|
||||
|
||||
// If apparently active, check if it's only bot activity
|
||||
if (!isStale) {
|
||||
try {
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100,
|
||||
sort: 'created',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
const lastHumanComment = comments.data.find(comment => comment.user.type !== 'Bot');
|
||||
if (lastHumanComment) {
|
||||
isStale = new Date(lastHumanComment.created_at) < tenDaysAgo;
|
||||
} else {
|
||||
// No human comments. Check if creator is human.
|
||||
if (issue.user.type !== 'Bot') {
|
||||
isStale = createdAt < tenDaysAgo;
|
||||
} else {
|
||||
isStale = true; // Bot created, only bot comments
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to fetch comments for issue #${issue.number}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStale) {
|
||||
processedCount++;
|
||||
const message = `Closing stale issue #${issue.number}: "${issue.title}" (${issue.html_url})`;
|
||||
core.info(message);
|
||||
|
||||
if (!dryRun) {
|
||||
// Add label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [batchLabel]
|
||||
});
|
||||
|
||||
// Add comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: 'Hello! As part of our effort to keep our backlog manageable and focus on the most active issues, we are tidying up older reports.\n\nIt looks like this issue hasn\'t been active for a while, so we are closing it for now. However, if you are still experiencing this bug on the latest stable build, please feel free to comment on this issue or create a new one with updated details.\n\nThank you for your contribution!'
|
||||
});
|
||||
|
||||
// Close issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.info(`\nTotal issues processed: ${processedCount}`);
|
||||
@@ -1,254 +0,0 @@
|
||||
name: 'Gemini Scheduled Stale PR Closer'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Every day at 2 AM UTC
|
||||
pull_request:
|
||||
types: ['opened', 'edited']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run in dry-run mode'
|
||||
required: false
|
||||
default: false
|
||||
type: 'boolean'
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
issues: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Process Stale PRs'
|
||||
uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7
|
||||
env:
|
||||
DRY_RUN: '${{ inputs.dry_run }}'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |
|
||||
const dryRun = process.env.DRY_RUN === 'true';
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
// 1. Fetch maintainers for verification
|
||||
let maintainerLogins = new Set();
|
||||
const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
|
||||
|
||||
for (const team_slug of teams) {
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: context.repo.owner,
|
||||
team_slug: team_slug
|
||||
});
|
||||
for (const m of members) maintainerLogins.add(m.login.toLowerCase());
|
||||
core.info(`Successfully fetched ${members.length} team members from ${team_slug}`);
|
||||
} catch (e) {
|
||||
// Silently skip if permissions are insufficient; we will rely on author_association
|
||||
core.debug(`Skipped team fetch for ${team_slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const isMaintainer = async (login, assoc) => {
|
||||
// Reliably identify maintainers using authorAssociation (provided by GitHub)
|
||||
// and organization membership (if available).
|
||||
const isTeamMember = maintainerLogins.has(login.toLowerCase());
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
||||
|
||||
if (isTeamMember || isRepoMaintainer) return true;
|
||||
|
||||
// Fallback: Check if user belongs to the 'google' or 'googlers' orgs (requires permission)
|
||||
try {
|
||||
const orgs = ['googlers', 'google'];
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({ org: org, username: login });
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Gracefully ignore failures here
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// 2. Fetch all open PRs
|
||||
let prs = [];
|
||||
if (context.eventName === 'pull_request') {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number
|
||||
});
|
||||
prs = [pr];
|
||||
} else {
|
||||
prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100
|
||||
});
|
||||
}
|
||||
|
||||
for (const pr of prs) {
|
||||
const maintainerPr = await isMaintainer(pr.user.login, pr.author_association);
|
||||
const isBot = pr.user.type === 'Bot' || pr.user.login.endsWith('[bot]');
|
||||
if (maintainerPr || isBot) continue;
|
||||
|
||||
// Helper: Fetch labels and linked issues via GraphQL
|
||||
const prDetailsQuery = `query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
labels(first: 20) {
|
||||
nodes { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
let linkedIssues = [];
|
||||
try {
|
||||
const res = await github.graphql(prDetailsQuery, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, number: pr.number
|
||||
});
|
||||
linkedIssues = res.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
} catch (e) {
|
||||
core.warning(`GraphQL fetch failed for PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
|
||||
// Check for mentions in body as fallback (regex)
|
||||
const body = pr.body || '';
|
||||
const mentionRegex = /(?:#|https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/)(\d+)/i;
|
||||
const matches = body.match(mentionRegex);
|
||||
if (matches && linkedIssues.length === 0) {
|
||||
const issueNumber = parseInt(matches[1]);
|
||||
try {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
linkedIssues = [{ number: issueNumber, labels: { nodes: issue.labels.map(l => ({ name: l.name })) } }];
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 3. Enforcement Logic
|
||||
const prLabels = pr.labels.map(l => l.name.toLowerCase());
|
||||
const hasHelpWanted = prLabels.includes('help wanted') ||
|
||||
linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === 'help wanted'));
|
||||
|
||||
const hasMaintainerOnly = prLabels.includes('🔒 maintainer only') ||
|
||||
linkedIssues.some(issue => issue.labels.nodes.some(l => l.name.toLowerCase() === '🔒 maintainer only'));
|
||||
|
||||
const hasLinkedIssue = linkedIssues.length > 0;
|
||||
|
||||
// Closure Policy: No help-wanted label = Close after 14 days
|
||||
if (pr.state === 'open' && !hasHelpWanted && !hasMaintainerOnly) {
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
|
||||
// We give a 14-day grace period for non-help-wanted PRs to be manually reviewed/labeled by an EM
|
||||
if (prCreatedAt > fourteenDaysAgo) {
|
||||
core.info(`PR #${pr.number} is new and lacks 'help wanted'. Giving 14-day grace period for EM review.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is older than 14 days and lacks 'help wanted' association. Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we have updated our contribution policy (see [Discussion #17383](https://github.com/google-gemini/gemini-cli/discussions/17383)). \n\n**We only *guarantee* review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'.** All other community pull requests are subject to closure after 14 days if they do not align with our current focus areas. For this reason, we strongly recommend that contributors only submit pull requests against issues explicitly labeled as **'help-wanted'**. \n\nThis pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding and for being part of our community!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also check for linked issue even if it has help wanted (redundant but safe)
|
||||
if (pr.state === 'open' && !hasLinkedIssue) {
|
||||
// Already covered by hasHelpWanted check above, but good for future-proofing
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Staleness Check (Scheduled only)
|
||||
if (pr.state === 'open' && context.eventName !== 'pull_request') {
|
||||
// Skip PRs that were created less than 30 days ago - they cannot be stale yet
|
||||
const prCreatedAt = new Date(pr.created_at);
|
||||
if (prCreatedAt > thirtyDaysAgo) continue;
|
||||
|
||||
let lastActivity = new Date(pr.created_at);
|
||||
try {
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number
|
||||
});
|
||||
for (const r of reviews) {
|
||||
if (await isMaintainer(r.user.login, r.author_association)) {
|
||||
const d = new Date(r.submitted_at || r.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number
|
||||
});
|
||||
for (const c of comments) {
|
||||
if (await isMaintainer(c.user.login, c.author_association)) {
|
||||
const d = new Date(c.updated_at);
|
||||
if (d > lastActivity) lastActivity = d;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (lastActivity < thirtyDaysAgo) {
|
||||
const labels = pr.labels.map(l => l.name.toLowerCase());
|
||||
const isProtected = labels.includes('help wanted') || labels.includes('🔒 maintainer only');
|
||||
if (isProtected) {
|
||||
core.info(`PR #${pr.number} is stale but has a protected label. Skipping closure.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
core.info(`PR #${pr.number} is stale (no maintainer activity for 30+ days). Closing.`);
|
||||
if (!dryRun) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: "Hi there! Thank you for your contribution. To keep our backlog manageable, we are closing pull requests that haven't seen maintainer activity for 30 days. If you're still working on this, please let us know!"
|
||||
});
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
name: 'No Response'
|
||||
|
||||
# Run as a daily cron at 1:45 AM
|
||||
on:
|
||||
schedule:
|
||||
- cron: '45 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
no-response:
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: |-
|
||||
${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-no-response'
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
days-before-stale: -1
|
||||
days-before-close: 14
|
||||
stale-issue-label: 'status/need-information'
|
||||
close-issue-message: >-
|
||||
This issue was marked as needing more information and has not received a response in 14 days.
|
||||
Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!
|
||||
stale-pr-label: 'status/need-information'
|
||||
close-pr-message: >-
|
||||
This pull request was marked as needing more information and has had no updates in 14 days.
|
||||
Closing it for now. You are welcome to reopen with the required info. Thanks for contributing!
|
||||
@@ -1,133 +0,0 @@
|
||||
name: '🏷️ PR Contribution Guidelines Notifier'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- 'opened'
|
||||
|
||||
jobs:
|
||||
notify-process-change:
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: |-
|
||||
github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli'
|
||||
permissions:
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
env:
|
||||
APP_ID: '${{ secrets.APP_ID }}'
|
||||
if: |-
|
||||
${{ env.APP_ID != '' }}
|
||||
uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
private-key: '${{ secrets.PRIVATE_KEY }}'
|
||||
|
||||
- name: 'Check membership and post comment'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
|
||||
script: |-
|
||||
const org = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const username = context.payload.pull_request.user.login;
|
||||
const pr_number = context.payload.pull_request.number;
|
||||
|
||||
// 1. Check if the PR author is a maintainer
|
||||
// Check team membership (most reliable for private org members)
|
||||
let isTeamMember = false;
|
||||
const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs'];
|
||||
for (const team_slug of teams) {
|
||||
try {
|
||||
const members = await github.paginate(github.rest.teams.listMembersInOrg, {
|
||||
org: org,
|
||||
team_slug: team_slug
|
||||
});
|
||||
if (members.some(m => m.login.toLowerCase() === username.toLowerCase())) {
|
||||
isTeamMember = true;
|
||||
core.info(`${username} is a member of ${team_slug}. No notification needed.`);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to fetch team members from ${team_slug}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isTeamMember) return;
|
||||
|
||||
// Check author_association from webhook payload
|
||||
const authorAssociation = context.payload.pull_request.author_association;
|
||||
const isRepoMaintainer = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation);
|
||||
|
||||
if (isRepoMaintainer) {
|
||||
core.info(`${username} is a maintainer (author_association: ${authorAssociation}). No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if author is a Googler
|
||||
const isGoogler = async (login) => {
|
||||
try {
|
||||
const orgs = ['googlers', 'google'];
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: org,
|
||||
username: login
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Failed to check org membership for ${login}: ${e.message}`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (await isGoogler(username)) {
|
||||
core.info(`${username} is a Googler. No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if the PR is already associated with an issue
|
||||
const query = `
|
||||
query($owner:String!, $repo:String!, $number:Int!) {
|
||||
repository(owner:$owner, name:$repo) {
|
||||
pullRequest(number:$number) {
|
||||
closingIssuesReferences(first: 1) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const variables = { owner: org, repo: repo, number: pr_number };
|
||||
const result = await github.graphql(query, variables);
|
||||
const issueCount = result.repository.pullRequest.closingIssuesReferences.totalCount;
|
||||
|
||||
if (issueCount > 0) {
|
||||
core.info(`PR #${pr_number} is already associated with an issue. No notification needed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Post the notification comment
|
||||
core.info(`${username} is not a maintainer and PR #${pr_number} has no linked issue. Posting notification.`);
|
||||
|
||||
const comment = `
|
||||
Hi @${username}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.
|
||||
|
||||
We're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](https://github.com/google-gemini/gemini-cli/discussions/16706).
|
||||
|
||||
Key Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.
|
||||
|
||||
Thank you for your understanding and for being a part of our community!
|
||||
`.trim().replace(/^[ ]+/gm, '');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: org,
|
||||
repo: repo,
|
||||
issue_number: pr_number,
|
||||
body: comment
|
||||
});
|
||||
@@ -70,6 +70,8 @@ jobs:
|
||||
- name: 'Generate Changelog with Gemini'
|
||||
if: "steps.validate_version.outputs.CONTINUE == 'true'"
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
prompt: |
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: 'Mark stale issues and pull requests'
|
||||
|
||||
# Run as a daily cron at 1:30 AM
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
runner:
|
||||
- 'ubuntu-latest' # GitHub-hosted
|
||||
runs-on: '${{ matrix.runner }}'
|
||||
if: |-
|
||||
${{ github.repository == 'google-gemini/gemini-cli' }}
|
||||
permissions:
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-stale'
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: 'actions/stale@5bef64f19d7facfb25b37b414482c7164d639639' # ratchet:actions/stale@v9
|
||||
with:
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
stale-issue-message: >-
|
||||
This issue has been automatically marked as stale due to 60 days of inactivity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
stale-pr-message: >-
|
||||
This pull request has been automatically marked as stale due to 60 days of inactivity.
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
close-issue-message: >-
|
||||
This issue has been closed due to 14 additional days of inactivity after being marked as stale.
|
||||
If you believe this is still relevant, feel free to comment or reopen the issue. Thank you!
|
||||
close-pr-message: >-
|
||||
This pull request has been closed due to 14 additional days of inactivity after being marked as stale.
|
||||
If this is still relevant, you are welcome to reopen or leave a comment. Thanks for contributing!
|
||||
days-before-stale: 60
|
||||
days-before-close: 14
|
||||
exempt-issue-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
|
||||
exempt-pr-labels: 'pinned,security,🔒 maintainer only,help wanted,🗓️ Public Roadmap'
|
||||
+44
-3
@@ -1,3 +1,44 @@
|
||||
# ---- Stage 1: Builder ----
|
||||
FROM docker.io/library/node:20-slim AS builder
|
||||
|
||||
# Install git (needed by generate-git-commit-info.js script)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy only package.json files first for better layer caching
|
||||
# Dependencies only re-install when package files change, not source files
|
||||
COPY package*.json ./
|
||||
COPY packages/cli/package*.json ./packages/cli/
|
||||
COPY packages/core/package*.json ./packages/core/
|
||||
COPY packages/vscode-ide-companion/package*.json ./packages/vscode-ide-companion/
|
||||
COPY packages/vscode-ide-companion/scripts/ ./packages/vscode-ide-companion/scripts/
|
||||
COPY packages/devtools/package*.json ./packages/devtools/
|
||||
COPY packages/sdk/package*.json ./packages/sdk/
|
||||
COPY packages/test-utils/package*.json ./packages/test-utils/
|
||||
COPY packages/a2a-server/package*.json ./packages/a2a-server/
|
||||
|
||||
# Use npm ci for consistent, reliable builds (respects package-lock.json)
|
||||
RUN HUSKY=0 npm ci --ignore-scripts
|
||||
|
||||
# Now copy the rest of the source (after install for better caching)
|
||||
COPY packages/ ./packages/
|
||||
COPY tsconfig*.json ./
|
||||
COPY eslint.config.js ./
|
||||
COPY scripts/ ./scripts/
|
||||
COPY esbuild.config.js ./
|
||||
|
||||
# Pass git commit hash as build arg instead of copying entire .git directory
|
||||
ARG GIT_COMMIT=unknown
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
|
||||
# Build and pack artifacts
|
||||
RUN HUSKY=0 npm run build && \
|
||||
npm pack -w packages/core --pack-destination packages/core/dist/ && \
|
||||
npm pack -w packages/cli --pack-destination packages/cli/dist/
|
||||
|
||||
# ---- Stage 2: Runtime ----
|
||||
FROM docker.io/library/node:20-slim
|
||||
|
||||
ARG SANDBOX_NAME="gemini-cli-sandbox"
|
||||
@@ -40,8 +81,8 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
USER node
|
||||
|
||||
# install gemini-cli and clean up
|
||||
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
COPY --chown=node:node packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY --chown=node:node packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& npm install -g /tmp/gemini-cli.tgz \
|
||||
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
|
||||
@@ -50,4 +91,4 @@ RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& rm -f /tmp/gemini-{cli,core}.tgz
|
||||
|
||||
# default entrypoint when none specified
|
||||
CMD ["gemini"]
|
||||
ENTRYPOINT ["/usr/local/share/npm-global/bin/gemini"]
|
||||
@@ -395,6 +395,16 @@ for removal instructions.
|
||||
[Terms & Privacy](https://www.geminicli.com/docs/resources/tos-privacy)
|
||||
- **Security**: [Security Policy](SECURITY.md)
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.star-history.com/google-gemini/gemini-cli">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=google-gemini/gemini-cli&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=google-gemini/gemini-cli" />
|
||||
<img alt="Star History Rank" src="https://api.star-history.com/badge?repo=google-gemini/gemini-cli" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -18,6 +18,42 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.40.0 - 2026-04-28
|
||||
|
||||
- **Offline Search and Themes:** Bundled ripgrep for offline search support and
|
||||
added GitHub-style colorblind themes
|
||||
([#25342](https://github.com/google-gemini/gemini-cli/pull/25342) by
|
||||
@scidomino, [#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
|
||||
by @Z1xus).
|
||||
- **Advanced Resource and Memory Management:** Introduced MCP resource tools and
|
||||
transitioned to a prompt-driven, four-tier memory management system
|
||||
([#25395](https://github.com/google-gemini/gemini-cli/pull/25395) by
|
||||
@ruomengz, [#25716](https://github.com/google-gemini/gemini-cli/pull/25716) by
|
||||
@SandyTao520).
|
||||
- **UX and Local Models:** Enabled topic update narrations by default and
|
||||
streamlined Gemma local model setup with `gemini gemma`
|
||||
([#25586](https://github.com/google-gemini/gemini-cli/pull/25586) by
|
||||
@gundermanc, [#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
|
||||
by @Samee24).
|
||||
|
||||
## Announcements: v0.39.0 - 2026-04-23
|
||||
|
||||
- **Skill Management:** Added a new `/memory` inbox command for reviewing and
|
||||
patching skills extracted during sessions
|
||||
([#24544](https://github.com/google-gemini/gemini-cli/pull/24544) by
|
||||
@SandyTao520, [#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
by @SandyTao520).
|
||||
- **Improved Transparency:** Plan Mode now requires confirmation for skill
|
||||
activation and allows plan inspection
|
||||
([#24946](https://github.com/google-gemini/gemini-cli/pull/24946),
|
||||
[#25058](https://github.com/google-gemini/gemini-cli/pull/25058) by
|
||||
@ruomengz).
|
||||
- **Architecture & Reliability:** Introduced a decoupled `ContextManager`
|
||||
architecture and resolved several critical memory leaks and PTY exhaustion
|
||||
issues ([#24752](https://github.com/google-gemini/gemini-cli/pull/24752) by
|
||||
@joshualitt, [#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
by @spencer426).
|
||||
|
||||
## Announcements: v0.38.0 - 2026-04-14
|
||||
|
||||
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
|
||||
|
||||
+168
-255
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.38.2
|
||||
# Latest stable release: v0.40.0
|
||||
|
||||
Released: April 17, 2026
|
||||
Released: April 28, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,264 +11,177 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters")
|
||||
to provide better session structure and narrative continuity in long-running
|
||||
tasks.
|
||||
- **Context Compression Service:** Implemented a dedicated service for advanced
|
||||
context management, efficiently distilling conversation history to preserve
|
||||
focus and tokens.
|
||||
- **Enhanced UI Stability & UX:** Introduced a new "Terminal Buffer" mode to
|
||||
solve rendering flicker, along with selective topic expansion and improved
|
||||
tool confirmation layouts.
|
||||
- **Context-Aware Policy Approvals:** Users can now grant persistent,
|
||||
context-aware approvals for tools, significantly reducing manual confirmation
|
||||
overhead for trusted workflows.
|
||||
- **Background Process Monitoring:** New tools for monitoring and inspecting
|
||||
background shell processes, providing better visibility into asynchronous
|
||||
tasks.
|
||||
- **Offline Search Support:** Bundled ripgrep binaries into the Single
|
||||
Executable Application (SEA) to enable powerful codebase searching even in
|
||||
environments without internet access.
|
||||
- **Enhanced Theme Customization:** Introduced GitHub-style colorblind-friendly
|
||||
themes to improve accessibility and provide more personalized visual options.
|
||||
- **MCP Resource Management:** Added new tools for listing and reading Model
|
||||
Context Protocol (MCP) resources, enhancing the agent's ability to discover
|
||||
and utilize external data.
|
||||
- **Improved Narrative Flow:** Enabled topic update narrations by default to
|
||||
provide better session structure and a clearer understanding of the agent's
|
||||
current focus.
|
||||
- **Streamlined Local Model Setup:** Introduced a simplified `gemini gemma`
|
||||
command for quickly setting up and running Gemma models locally.
|
||||
- **Prompt-Driven Memory Management:** Replaced the legacy `MemoryManagerAgent`
|
||||
with a more efficient prompt-driven memory editing system across four tiers of
|
||||
context.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 14b2f35 to release/v0.38.1-pr-24974 to patch version
|
||||
v0.38.1 and create version 0.38.2 by @gemini-cli-robot in
|
||||
[#25585](https://github.com/google-gemini/gemini-cli/pull/25585)
|
||||
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
|
||||
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
|
||||
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
|
||||
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
|
||||
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
|
||||
- Update README.md for links. by @g-samroberts in
|
||||
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
|
||||
- fix(core): ensure complete_task tool calls are recorded in chat history by
|
||||
@abhipatel12 in
|
||||
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
|
||||
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
|
||||
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
|
||||
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
|
||||
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
|
||||
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
|
||||
by @adamfweidman in
|
||||
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
|
||||
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
|
||||
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
|
||||
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
|
||||
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
|
||||
- fix(cli): ensure agent stops when all declinable tools are cancelled by
|
||||
@NTaylorMullen in
|
||||
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
|
||||
- fix(core): enhance sandbox usability and fix build error by @galz10 in
|
||||
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
|
||||
- Terminal Serializer Optimization by @jacob314 in
|
||||
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
|
||||
- Auto configure memory. by @jacob314 in
|
||||
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
|
||||
- Unused error variables in catch block are not allowed by @alisa-alisa in
|
||||
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
|
||||
- feat(core): add background memory service for skill extraction by @SandyTao520
|
||||
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
|
||||
- feat: implement high-signal PR regression check for evaluations by
|
||||
@alisa-alisa in
|
||||
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
|
||||
- Fix shell output display by @jacob314 in
|
||||
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
|
||||
- fix(ui): resolve unwanted vertical spacing around various tool output
|
||||
treatments by @jwhelangoog in
|
||||
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
|
||||
- revert(cli): bring back input box and footer visibility in copy mode by
|
||||
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
|
||||
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
|
||||
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
|
||||
- feat(cli): support default values for environment variables by @ruomengz in
|
||||
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
|
||||
- Implement background process monitoring and inspection tools by @cocosheng-g
|
||||
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
|
||||
- docs(browser-agent): update stale browser agent documentation by @gsquared94
|
||||
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
|
||||
- fix: enable browser_agent in integration tests and add localhost fixture tests
|
||||
by @gsquared94 in
|
||||
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
|
||||
- fix(browser): handle computer-use model detection for analyze_screenshot by
|
||||
@gsquared94 in
|
||||
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
|
||||
- feat(core): Land ContextCompressionService by @joshualitt in
|
||||
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
|
||||
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
|
||||
- chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a by
|
||||
@gemini-cli-robot in
|
||||
[#25420](https://github.com/google-gemini/gemini-cli/pull/25420)
|
||||
- Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075)
|
||||
by @rcleveng in
|
||||
[#25187](https://github.com/google-gemini/gemini-cli/pull/25187)
|
||||
- fix(core): prevent YOLO mode from being downgraded by @galz10 in
|
||||
[#25341](https://github.com/google-gemini/gemini-cli/pull/25341)
|
||||
- feat: bundle ripgrep binaries into SEA for offline support by @scidomino in
|
||||
[#25342](https://github.com/google-gemini/gemini-cli/pull/25342)
|
||||
- Changelog for v0.39.0-preview.0 by @gemini-cli-robot in
|
||||
[#25417](https://github.com/google-gemini/gemini-cli/pull/25417)
|
||||
- feat(test): add large conversation scenario for performance test by
|
||||
@cynthialong0-0 in
|
||||
[#25331](https://github.com/google-gemini/gemini-cli/pull/25331)
|
||||
- improve(core): require recurrence evidence before extracting skills by
|
||||
@SandyTao520 in
|
||||
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
|
||||
- Update ink version to 6.6.7 by @jacob314 in
|
||||
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
|
||||
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
|
||||
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
|
||||
- Fix crash when vim editor is not found in PATH on Windows by
|
||||
@Nagajyothi-tammisetti in
|
||||
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
|
||||
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
|
||||
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
|
||||
- Enable 'Other' option for yesno question type by @ruomengz in
|
||||
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
|
||||
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
|
||||
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
|
||||
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
|
||||
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
|
||||
- feat(core): implement context-aware persistent policy approvals by @jerop in
|
||||
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
|
||||
- docs: move agent disabling instructions and update remote agent status by
|
||||
@jackwotherspoon in
|
||||
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
|
||||
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
|
||||
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
|
||||
- fix(core): unsafe type assertions in Core File System #19712 by
|
||||
@aniketsaurav18 in
|
||||
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
|
||||
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
|
||||
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
|
||||
- Changelog for v0.36.0 by @gemini-cli-robot in
|
||||
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
|
||||
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
|
||||
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
|
||||
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
|
||||
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
|
||||
- fix(ui): fixed table styling by @devr0306 in
|
||||
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
|
||||
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
|
||||
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
|
||||
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
|
||||
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
|
||||
- docs: clarify release coordination by @scidomino in
|
||||
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
|
||||
- fix(core): remove broken PowerShell translation and fix native \_\_write in
|
||||
Windows sandbox by @scidomino in
|
||||
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
|
||||
- Add instructions for how to start react in prod and force react to prod mode
|
||||
by @jacob314 in
|
||||
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
|
||||
- feat(cli): minimalist sandbox status labels by @galz10 in
|
||||
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
|
||||
- Feat/browser agent metrics by @kunal-10-cloud in
|
||||
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
|
||||
- test: fix Windows CI execution and resolve exposed platform failures by
|
||||
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
|
||||
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
|
||||
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
|
||||
- show color by @jacob314 in
|
||||
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
|
||||
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
|
||||
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
|
||||
- fix(core): inject skill system instructions into subagent prompts if activated
|
||||
by @abhipatel12 in
|
||||
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
|
||||
- fix(core): improve windows sandbox reliability and fix integration tests by
|
||||
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
|
||||
- fix(core): ensure sandbox approvals are correctly persisted and matched for
|
||||
proactive expansions by @galz10 in
|
||||
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
|
||||
- feat(cli) Scrollbar for input prompt by @jacob314 in
|
||||
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
|
||||
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
|
||||
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
|
||||
- Fix restoration of topic headers. by @gundermanc in
|
||||
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
|
||||
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
|
||||
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
|
||||
- fix(core): ensure global temp directory is always in sandbox allowed paths by
|
||||
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
|
||||
- fix(core): detect uninitialized lines by @jacob314 in
|
||||
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
|
||||
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
|
||||
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
|
||||
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
|
||||
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
|
||||
- feat(acp): add support for `/about` command by @sripasg in
|
||||
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
|
||||
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
|
||||
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
|
||||
- split context by @jacob314 in
|
||||
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
|
||||
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
|
||||
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
|
||||
- Fix issue where topic headers can be posted back to back by @gundermanc in
|
||||
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
|
||||
- fix(core): handle partial llm_request in BeforeModel hook override by
|
||||
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
|
||||
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
|
||||
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
|
||||
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
|
||||
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
|
||||
- fix(browser): remove premature browser cleanup after subagent invocation by
|
||||
@gsquared94 in
|
||||
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
|
||||
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
|
||||
@Abhijit-2592 in
|
||||
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
|
||||
- relax tool sandboxing overrides for plan mode to match defaults. by
|
||||
@DavidAPierce in
|
||||
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
|
||||
- fix(cli): respect global environment variable allowlist by @scidomino in
|
||||
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
|
||||
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
|
||||
by @spencer426 in
|
||||
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
|
||||
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
|
||||
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
|
||||
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
|
||||
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
|
||||
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
|
||||
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
|
||||
- feat(cli): support selective topic expansion and click-to-expand by
|
||||
@Abhijit-2592 in
|
||||
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
|
||||
- temporarily disable sandbox integration test on windows by @ehedlund in
|
||||
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
|
||||
- Remove flakey test by @scidomino in
|
||||
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
|
||||
- Alisa/approve button by @alisa-alisa in
|
||||
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
|
||||
- feat(hooks): display hook system messages in UI by @mbleigh in
|
||||
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
|
||||
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
|
||||
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
|
||||
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
|
||||
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
|
||||
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
|
||||
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
|
||||
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
|
||||
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
|
||||
- feat(acp): add /help command by @sripasg in
|
||||
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
|
||||
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
|
||||
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
|
||||
- Improve sandbox error matching and caching by @DavidAPierce in
|
||||
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
|
||||
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
|
||||
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
|
||||
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
|
||||
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
|
||||
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
|
||||
[#25147](https://github.com/google-gemini/gemini-cli/pull/25147)
|
||||
- test(evals): add subagent delegation evaluation tests by @anj-s in
|
||||
[#24619](https://github.com/google-gemini/gemini-cli/pull/24619)
|
||||
- feat: add github colorblind themes by @Z1xus in
|
||||
[#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
|
||||
- fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL by
|
||||
@chrisjcthomas in
|
||||
[#25357](https://github.com/google-gemini/gemini-cli/pull/25357)
|
||||
- fix(cli): clean up slash command IDE listeners by @jasonmatthewsuhari in
|
||||
[#24397](https://github.com/google-gemini/gemini-cli/pull/24397)
|
||||
- Changelog for v0.38.0 by @gemini-cli-robot in
|
||||
[#25470](https://github.com/google-gemini/gemini-cli/pull/25470)
|
||||
- fix(evals): update eval tests for invoke_agent telemetry and project-scoped
|
||||
memory by @SandyTao520 in
|
||||
[#25502](https://github.com/google-gemini/gemini-cli/pull/25502)
|
||||
- Changelog for v0.38.1 by @gemini-cli-robot in
|
||||
[#25476](https://github.com/google-gemini/gemini-cli/pull/25476)
|
||||
- feat(core): integrate skill-creator into skill extraction agent by
|
||||
@SandyTao520 in
|
||||
[#25421](https://github.com/google-gemini/gemini-cli/pull/25421)
|
||||
- feat(cli): provide default post-submit prompt for skill command by @ruomengz
|
||||
in [#25327](https://github.com/google-gemini/gemini-cli/pull/25327)
|
||||
- feat(core): add tools to list and read MCP resources by @ruomengz in
|
||||
[#25395](https://github.com/google-gemini/gemini-cli/pull/25395)
|
||||
- fix(evals): add typecheck coverage for evals, integration-tests, and
|
||||
memory-tests by @SandyTao520 in
|
||||
[#25480](https://github.com/google-gemini/gemini-cli/pull/25480)
|
||||
- Use OSC 777 for terminal notifications by @jackyliuxx in
|
||||
[#25300](https://github.com/google-gemini/gemini-cli/pull/25300)
|
||||
- fix(extensions): fix bundling for examples by @abhipatel12 in
|
||||
[#25542](https://github.com/google-gemini/gemini-cli/pull/25542)
|
||||
- fix(cli): reset plan session state on /clear by @jasonmatthewsuhari in
|
||||
[#25515](https://github.com/google-gemini/gemini-cli/pull/25515)
|
||||
- feat(core): add .mdx support to get-internal-docs tool by @g-samroberts in
|
||||
[#25090](https://github.com/google-gemini/gemini-cli/pull/25090)
|
||||
- docs(policy): mention that workspace policies are broken by @6112 in
|
||||
[#24367](https://github.com/google-gemini/gemini-cli/pull/24367)
|
||||
- fix(core): allow explicit write permissions to override governance file
|
||||
protections in sandboxes by @galz10 in
|
||||
[#25338](https://github.com/google-gemini/gemini-cli/pull/25338)
|
||||
- feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first by
|
||||
@mvanhorn in [#25427](https://github.com/google-gemini/gemini-cli/pull/25427)
|
||||
- Reduce blank lines. by @gundermanc in
|
||||
[#25563](https://github.com/google-gemini/gemini-cli/pull/25563)
|
||||
- fix(ui): revert preview theme on dialog unmount by @JayadityaGit in
|
||||
[#22542](https://github.com/google-gemini/gemini-cli/pull/22542)
|
||||
- fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save
|
||||
backoff by @mahimashanware in
|
||||
[#25382](https://github.com/google-gemini/gemini-cli/pull/25382)
|
||||
- feat(core): Disable topic updates for subagents by @gundermanc in
|
||||
[#25567](https://github.com/google-gemini/gemini-cli/pull/25567)
|
||||
- feat(core): enable topic update narration by default and promote to general by
|
||||
@gundermanc in
|
||||
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
|
||||
- refactor(cli): remove duplication in interactive shell awaiting input hint by
|
||||
[#25586](https://github.com/google-gemini/gemini-cli/pull/25586)
|
||||
- docs: migrate installation and authentication to mdx with tabbed layouts by
|
||||
@g-samroberts in
|
||||
[#25155](https://github.com/google-gemini/gemini-cli/pull/25155)
|
||||
- feat(config): split memoryManager flag into autoMemory by @SandyTao520 in
|
||||
[#25601](https://github.com/google-gemini/gemini-cli/pull/25601)
|
||||
- fix(core): allow Cloud Shell users to use PRO_MODEL_NO_ACCESS experiment by
|
||||
@sehoon38 in [#25702](https://github.com/google-gemini/gemini-cli/pull/25702)
|
||||
- fix(cli): round slow render latency to avoid opentelemetry float warning by
|
||||
@scidomino in [#25709](https://github.com/google-gemini/gemini-cli/pull/25709)
|
||||
- docs(tracker): introduce experimental task tracker feature by @anj-s in
|
||||
[#24556](https://github.com/google-gemini/gemini-cli/pull/24556)
|
||||
- docs(cli): fix inconsistent system.md casing in system prompt docs by @Bodlux
|
||||
in [#25414](https://github.com/google-gemini/gemini-cli/pull/25414)
|
||||
- feat(cli): add streamlined `gemini gemma` local model setup by @Samee24 in
|
||||
[#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
|
||||
- Changelog for v0.38.2 by @gemini-cli-robot in
|
||||
[#25593](https://github.com/google-gemini/gemini-cli/pull/25593)
|
||||
- Fix: Disallow overriding IDE stdio via workspace .env (RCE) by @M0nd0R in
|
||||
[#25022](https://github.com/google-gemini/gemini-cli/pull/25022)
|
||||
- feat(test): refactor the memory usage test to use metrics from CLI process
|
||||
instead of test runner by @cynthialong0-0 in
|
||||
[#25708](https://github.com/google-gemini/gemini-cli/pull/25708)
|
||||
- feat(vertex): add settings for Vertex AI request routing by @gordonhwc in
|
||||
[#25513](https://github.com/google-gemini/gemini-cli/pull/25513)
|
||||
- Fix/allow for session persistence by @ahsanfarooq210 in
|
||||
[#25176](https://github.com/google-gemini/gemini-cli/pull/25176)
|
||||
- Allow dots on GEMINI_API_KEY by @DKbyo in
|
||||
[#25497](https://github.com/google-gemini/gemini-cli/pull/25497)
|
||||
- feat(telemetry): add flag for enabling traces specifically by @spencer426 in
|
||||
[#25343](https://github.com/google-gemini/gemini-cli/pull/25343)
|
||||
- fix(core): resolve nested plan directory duplication and relative path
|
||||
policies by @mahimashanware in
|
||||
[#25138](https://github.com/google-gemini/gemini-cli/pull/25138)
|
||||
- feat: detect new files in @ recommendations with watcher based updates by
|
||||
@prassamin in [#25256](https://github.com/google-gemini/gemini-cli/pull/25256)
|
||||
- fix(cli): use newline in shell command wrapping to avoid breaking heredocs by
|
||||
@cocosheng-g in
|
||||
[#25537](https://github.com/google-gemini/gemini-cli/pull/25537)
|
||||
- fix(cli): ensure theme dialog labels are rendered for all themes by
|
||||
@JayadityaGit in
|
||||
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
|
||||
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
|
||||
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
|
||||
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
|
||||
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
|
||||
- fix(cli): always show shell command description or actual command by @jacob314
|
||||
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
|
||||
- Added flag for ept size and increased default size by @devr0306 in
|
||||
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
|
||||
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
|
||||
@Anjaligarhwal in
|
||||
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
|
||||
- fix(cli): switch default back to terminalBuffer=false and fix regressions
|
||||
introduced for that mode by @jacob314 in
|
||||
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
|
||||
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
|
||||
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
|
||||
- fix: isolate concurrent browser agent instances by @gsquared94 in
|
||||
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
|
||||
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
|
||||
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
|
||||
[#24599](https://github.com/google-gemini/gemini-cli/pull/24599)
|
||||
- fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child
|
||||
processes by @euxaristia in
|
||||
[#22620](https://github.com/google-gemini/gemini-cli/pull/22620)
|
||||
- feat: add /new as alias for /clear and refine command description by @ved015
|
||||
in [#17865](https://github.com/google-gemini/gemini-cli/pull/17865)
|
||||
- fix(cli): start auto memory in ACP sessions by @jasonmatthewsuhari in
|
||||
[#25626](https://github.com/google-gemini/gemini-cli/pull/25626)
|
||||
- fix(core): remove duplicate initialize call on agents refreshed by
|
||||
@adamfweidman in
|
||||
[#25670](https://github.com/google-gemini/gemini-cli/pull/25670)
|
||||
- test(e2e): default integration tests to Flash Preview by @SandyTao520 in
|
||||
[#25753](https://github.com/google-gemini/gemini-cli/pull/25753)
|
||||
- refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing
|
||||
across four tiers by @SandyTao520 in
|
||||
[#25716](https://github.com/google-gemini/gemini-cli/pull/25716)
|
||||
- fix(cli): fix "/clear (new)" command by @mini2s in
|
||||
[#25801](https://github.com/google-gemini/gemini-cli/pull/25801)
|
||||
- fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0'
|
||||
by @thekishandev in
|
||||
[#24414](https://github.com/google-gemini/gemini-cli/pull/24414)
|
||||
- fix(core): handle line endings in ignore file parsing by @xoma-zver in
|
||||
[#23895](https://github.com/google-gemini/gemini-cli/pull/23895)
|
||||
- Fix/command injection shell by @Famous077 in
|
||||
[#24170](https://github.com/google-gemini/gemini-cli/pull/24170)
|
||||
- fix(ui): removed background color for input by @devr0306 in
|
||||
[#25339](https://github.com/google-gemini/gemini-cli/pull/25339)
|
||||
- fix(devtools): reduce memory usage and defer connection by @SandyTao520 in
|
||||
[#24496](https://github.com/google-gemini/gemini-cli/pull/24496)
|
||||
- fix(core): support jsonl session logs in memory and summary services by
|
||||
@SandyTao520 in
|
||||
[#25816](https://github.com/google-gemini/gemini-cli/pull/25816)
|
||||
- fix(release): exclude ripgrep binaries from npm tarballs by @SandyTao520 in
|
||||
[#25841](https://github.com/google-gemini/gemini-cli/pull/25841)
|
||||
- fix(patch): cherry-pick 048bf6e to release/v0.40.0-preview.3-pr-25941 to patch
|
||||
version v0.40.0-preview.3 and create version 0.40.0-preview.4 by
|
||||
@gemini-cli-robot in
|
||||
[#25942](https://github.com/google-gemini/gemini-cli/pull/25942)
|
||||
- fix(patch): cherry-pick 54b7586 to release/v0.40.0-preview.4-pr-26066
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#26124](https://github.com/google-gemini/gemini-cli/pull/26124)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.39.1...v0.40.0
|
||||
|
||||
+100
-236
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.39.0-preview.0
|
||||
# Preview release: v0.41.0-preview.0
|
||||
|
||||
Released: April 14, 2026
|
||||
Released: April 28, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,245 +13,109 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Refactored Subagents and Unified Tooling:** Consolidate subagent tools into
|
||||
a single `invoke_subagent` tool, removed legacy wrapping tools, and improved
|
||||
turn limits for codebase investigator.
|
||||
- **Advanced Memory and Skill Management:** Introduced `/memory` inbox for
|
||||
reviewing extracted skills and added skill patching support, enhancing agent
|
||||
learning and persistence.
|
||||
- **Expanded Test and Evaluation Infrastructure:** Added memory and CPU
|
||||
performance integration test harnesses and generalized evaluation
|
||||
infrastructure for better suite organization.
|
||||
- **Sandbox and Security Hardening:** Centralized sandbox paths for Linux and
|
||||
macOS, enforced read-only security for async git worktree resolution, and
|
||||
optimized Windows sandbox initialization.
|
||||
- **Enhanced CLI UX and UI Stability:** Improved scroll momentum, added a
|
||||
`debugRainbow` setting, and resolved various memory leaks and PTY exhaustion
|
||||
issues for a smoother terminal experience.
|
||||
- **Real-Time Voice Mode:** Implemented a new real-time voice mode supporting
|
||||
both cloud and local backends for a more interactive experience.
|
||||
- **Enhanced Security & Trust:** Enforced workspace trust in headless mode and
|
||||
secured `.env` file loading to improve system integrity.
|
||||
- **Expanded Model Support:** Added experimental support for Gemma 4 models in
|
||||
both core and CLI packages.
|
||||
- **Improved Core Infrastructure:** Wired up new `ContextManager` and
|
||||
`AgentChatHistory` for better state management, and optimized boot performance
|
||||
by fetching experiments and quota asynchronously.
|
||||
- **New Developer Tools & UX:** Added support for output redirection for CLI
|
||||
commands, manual session UUIDs via command-line arguments, and persistent
|
||||
auto-memory scratchpad for skill extraction.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- refactor(plan): simplify policy priorities and consolidate read-only rules by
|
||||
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
|
||||
- feat(test-utils): add memory usage integration test harness by @sripasg in
|
||||
[#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
|
||||
- feat(memory): add /memory inbox command for reviewing extracted skills by
|
||||
@SandyTao520 in
|
||||
[#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
|
||||
- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
|
||||
- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by
|
||||
@gemini-cli-robot in
|
||||
[#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
|
||||
- fix(core): ensure robust sandbox cleanup in all process execution paths by
|
||||
@ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
|
||||
- chore: update ink version to 6.6.8 by @jacob314 in
|
||||
[#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
|
||||
- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
|
||||
[#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
|
||||
- chore: ignore conductor directory by @JayadityaGit in
|
||||
[#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
|
||||
- Changelog for v0.37.0 by @gemini-cli-robot in
|
||||
[#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
|
||||
- feat(plan): require user confirmation for activate_skill in Plan Mode by
|
||||
@ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
|
||||
- feat(test-utils): add CPU performance integration test harness by @sripasg in
|
||||
[#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
|
||||
- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
|
||||
@dogukanozen in
|
||||
[#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
|
||||
- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
|
||||
[#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
|
||||
- fix(core): resolve windows symlink bypass and stabilize sandbox integration
|
||||
tests by @ehedlund in
|
||||
[#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
|
||||
- fix(cli): restore file path display in edit and write tool confirmations by
|
||||
@jwhelangoog in
|
||||
[#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
|
||||
- feat(core): refine shell tool description display logic by @jwhelangoog in
|
||||
[#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
|
||||
- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
|
||||
in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
|
||||
- Update ink version to 6.6.9 by @jacob314 in
|
||||
[#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
|
||||
- Generalize evals infra to support more types of evals, organization and
|
||||
queuing of named suites by @gundermanc in
|
||||
[#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
|
||||
- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
|
||||
[#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
|
||||
- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
|
||||
implementation by @ehedlund in
|
||||
[#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
|
||||
- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
|
||||
[#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
|
||||
- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
|
||||
[#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
|
||||
- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
|
||||
in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
|
||||
- Support ctrl+shift+g by @jacob314 in
|
||||
[#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
|
||||
- feat(core): refactor subagent tool to unified invoke_subagent tool by
|
||||
@abhipatel12 in
|
||||
[#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
|
||||
- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
|
||||
error by @mrpmohiburrahman in
|
||||
[#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
|
||||
- fix: respect hideContextPercentage when FooterConfigDialog is closed without
|
||||
changes by @chernistry in
|
||||
[#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
|
||||
- fix(cli): suppress unhandled AbortError logs during request cancellation by
|
||||
@euxaristia in
|
||||
[#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
|
||||
- Automated documentation audit by @g-samroberts in
|
||||
[#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
|
||||
- feat(cli): implement useAgentStream hook by @mbleigh in
|
||||
[#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
|
||||
- refactor(plan) Clean default plan toml by @ruomengz in
|
||||
[#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
|
||||
- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
|
||||
[#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
|
||||
- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
|
||||
[#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
|
||||
- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
|
||||
@abhipatel12 in
|
||||
[#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
|
||||
- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
|
||||
[#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
|
||||
- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
|
||||
[#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
|
||||
- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
|
||||
[#25847](https://github.com/google-gemini/gemini-cli/pull/25847)
|
||||
- fix(core): only show `list` suggestion if the partial input is empty by
|
||||
@cynthialong0-0 in
|
||||
[#25821](https://github.com/google-gemini/gemini-cli/pull/25821)
|
||||
- feat(cli): secure .env loading and enforce workspace trust in headless mode by
|
||||
@ehedlund in [#25814](https://github.com/google-gemini/gemini-cli/pull/25814)
|
||||
- fix: fatal hard-crash on loop detection via unhandled AbortError by @hsm207 in
|
||||
[#20108](https://github.com/google-gemini/gemini-cli/pull/20108)
|
||||
- update package-lock.json by @ehedlund in
|
||||
[#25876](https://github.com/google-gemini/gemini-cli/pull/25876)
|
||||
- feat(core): enhance shell command validation and add core tools allowlist by
|
||||
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
|
||||
- fix(ui): corrected background color check in user message components by
|
||||
@devr0306 in [#25880](https://github.com/google-gemini/gemini-cli/pull/25880)
|
||||
- perf(core): fix slow boot by fetching experiments and quota asynchronously by
|
||||
@spencer426 in
|
||||
[#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
|
||||
- fix(sandbox): centralize async git worktree resolution and enforce read-only
|
||||
security by @ehedlund in
|
||||
[#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
|
||||
- feat(test): add high-volume shell test and refine perf harness by @sripasg in
|
||||
[#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
|
||||
- fix(core): silently handle EPERM when listing dir structure by @scidomino in
|
||||
[#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
|
||||
- Changelog for v0.37.1 by @gemini-cli-robot in
|
||||
[#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
|
||||
- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
|
||||
@kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
|
||||
- Automated documentation audit results by @g-samroberts in
|
||||
[#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
|
||||
- debugging(ui): add optional debugRainbow setting by @jacob314 in
|
||||
[#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
|
||||
- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
|
||||
by @spencer426 in
|
||||
[#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
|
||||
- docs(cli): updates f12 description to be more precise by @JayadityaGit in
|
||||
[#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
|
||||
- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
|
||||
[#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
|
||||
- fix(core): remove buffer slice to prevent OOM on large output streams by
|
||||
@spencer426 in
|
||||
[#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
|
||||
- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
|
||||
[#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
|
||||
- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
|
||||
in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
|
||||
- refactor(core): consolidate execute() arguments into ExecuteOptions by
|
||||
@mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
|
||||
- feat(core): add Strategic Re-evaluation guidance to system prompt by
|
||||
@aishaneeshah in
|
||||
[#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
|
||||
- fix(core): preserve shell execution config fields on update by
|
||||
@jasonmatthewsuhari in
|
||||
[#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
|
||||
- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
|
||||
[#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
|
||||
- fix(cli): pass session id to interactive shell executions by
|
||||
@jasonmatthewsuhari in
|
||||
[#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
|
||||
- fix(cli): resolve text sanitization data loss due to C1 control characters by
|
||||
@euxaristia in
|
||||
[#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
|
||||
- feat(core): add large memory regression test by @cynthialong0-0 in
|
||||
[#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
|
||||
- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
|
||||
@spencer426 in
|
||||
[#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
|
||||
[#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
|
||||
- perf(sandbox): optimize Windows sandbox initialization via native ACL
|
||||
application by @ehedlund in
|
||||
[#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
|
||||
- chore: switch from keytar to @github/keytar by @cocosheng-g in
|
||||
[#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
|
||||
- fix: improve audio MIME normalization and validation in file reads by
|
||||
@junaiddshaukat in
|
||||
[#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
|
||||
- docs: Update docs-audit to include changes in PR body by @g-samroberts in
|
||||
[#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
|
||||
- docs: correct documentation for enforced authentication type by @cocosheng-g
|
||||
in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
|
||||
- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
|
||||
in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
|
||||
- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
|
||||
[#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
|
||||
- fix(core): fix quota footer for non-auto models and improve display by
|
||||
@jackwotherspoon in
|
||||
[#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
|
||||
- docs(contributing): clarify self-assignment policy for issues by @jmr in
|
||||
[#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
|
||||
- feat(core): add skill patching support with /memory inbox integration by
|
||||
[#25758](https://github.com/google-gemini/gemini-cli/pull/25758)
|
||||
- feat(core,cli): add support for Gemma 4 models (experimental) by @Abhijit-2592
|
||||
in [#25604](https://github.com/google-gemini/gemini-cli/pull/25604)
|
||||
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
|
||||
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
|
||||
- docs: add Gemini CLI course link to README by @JayadityaGit in
|
||||
[#25925](https://github.com/google-gemini/gemini-cli/pull/25925)
|
||||
- feat(repo): add gemini-cli-bot metrics and workflows by @gundermanc in
|
||||
[#25888](https://github.com/google-gemini/gemini-cli/pull/25888)
|
||||
- fix(cli): allow output redirection for cli commands by @spencer426 in
|
||||
[#25894](https://github.com/google-gemini/gemini-cli/pull/25894)
|
||||
- fix(core): fail closed in YOLO mode when shell parsing fails for restricted
|
||||
rules by @ehedlund in
|
||||
[#25935](https://github.com/google-gemini/gemini-cli/pull/25935)
|
||||
- fix(cli-ui): revert backspace handling to fix Windows regression by @scidomino
|
||||
in [#25941](https://github.com/google-gemini/gemini-cli/pull/25941)
|
||||
- feat(voice): implement real-time voice mode with cloud and local backends by
|
||||
@Abhijit-2592 in
|
||||
[#24174](https://github.com/google-gemini/gemini-cli/pull/24174)
|
||||
- Changelog for v0.39.0 by @gemini-cli-robot in
|
||||
[#25848](https://github.com/google-gemini/gemini-cli/pull/25848)
|
||||
- feat(memory): persist auto-memory scratchpad for skill extraction by
|
||||
@SandyTao520 in
|
||||
[#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
- Stop suppressing thoughts and text in model response by @gundermanc in
|
||||
[#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
|
||||
- fix(release): prefix git hash in nightly versions to prevent semver
|
||||
normalization by @SandyTao520 in
|
||||
[#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
|
||||
- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
|
||||
in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
|
||||
- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
|
||||
[#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
|
||||
- feat(ui): added enhancements to scroll momentum by @devr0306 in
|
||||
[#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
|
||||
- fix(core): replace custom binary detection with isbinaryfile to correctly
|
||||
handle UTF-8 (U+FFFD) by @Anjaligarhwal in
|
||||
[#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
|
||||
- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
|
||||
@mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
|
||||
- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
|
||||
[#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
|
||||
- feat: support auth block in MCP servers config in agents by @TanmayVartak in
|
||||
[#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
|
||||
- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
|
||||
[#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
|
||||
- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
|
||||
[#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
|
||||
- fix: correct redirect count increment in fetchJson by @KevinZhao in
|
||||
[#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
|
||||
- fix(core): prevent secondary crash in ModelRouterService finally block by
|
||||
@gundermanc in
|
||||
[#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
|
||||
- feat(core): introduce decoupled ContextManager and Sidecar architecture by
|
||||
@joshualitt in
|
||||
[#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
|
||||
- docs(core): update generalist agent documentation by @abhipatel12 in
|
||||
[#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
|
||||
- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
|
||||
in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
|
||||
- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
|
||||
in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
|
||||
- test(core): improve sandbox integration test coverage and fix OS-specific
|
||||
failures by @ehedlund in
|
||||
[#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
|
||||
- fix(core): use debug level for keychain fallback logging by @ehedlund in
|
||||
[#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
|
||||
- feat(test): add a performance test in asian language by @cynthialong0-0 in
|
||||
[#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
|
||||
- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
|
||||
answers by @Adib234 in
|
||||
[#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
|
||||
- fix(core): detect kmscon terminal as supporting true color by @claygeo in
|
||||
[#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
|
||||
- ci: add agent session drift check workflow by @adamfweidman in
|
||||
[#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
|
||||
- use macos-latest-large runner where applicable. by @scidomino in
|
||||
[#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
|
||||
- Changelog for v0.37.2 by @gemini-cli-robot in
|
||||
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
|
||||
[#25873](https://github.com/google-gemini/gemini-cli/pull/25873)
|
||||
- fix(cli): add missing response key to custom theme text schema by @gaurav0107
|
||||
in [#25822](https://github.com/google-gemini/gemini-cli/pull/25822)
|
||||
- fix(cli): provide manual update command when automatic update fails by
|
||||
@cocosheng-g in
|
||||
[#26052](https://github.com/google-gemini/gemini-cli/pull/26052)
|
||||
- test(cli): add unit tests for restore ACP command (#23402) by @cocosheng-g in
|
||||
[#26053](https://github.com/google-gemini/gemini-cli/pull/26053)
|
||||
- fix(ui): better error messages for ECONNRESET and ETIMEDOUT by @devr0306 in
|
||||
[#26059](https://github.com/google-gemini/gemini-cli/pull/26059)
|
||||
- feat(core): wire up the new ContextManager and AgentChatHistory by @joshualitt
|
||||
in [#25409](https://github.com/google-gemini/gemini-cli/pull/25409)
|
||||
- fix(cli): ensure sandbox proxy cleanup and remove handler leaks by @ehedlund
|
||||
in [#26065](https://github.com/google-gemini/gemini-cli/pull/26065)
|
||||
- fix(cli): correct alternate buffer warning logic for JetBrains by @Adib234 in
|
||||
[#26067](https://github.com/google-gemini/gemini-cli/pull/26067)
|
||||
- fix(cli): make MCP ping optional in list command and use configured timeout by
|
||||
@cocosheng-g in
|
||||
[#26068](https://github.com/google-gemini/gemini-cli/pull/26068)
|
||||
- fix(core): better error message for failed cloudshell-gca auth by @devr0306 in
|
||||
[#26079](https://github.com/google-gemini/gemini-cli/pull/26079)
|
||||
- feat(cli): provide manual session UUID via command line arg by @cocosheng-g in
|
||||
[#26060](https://github.com/google-gemini/gemini-cli/pull/26060)
|
||||
- Changelog for v0.40.0-preview.2 by @gemini-cli-robot in
|
||||
[#25846](https://github.com/google-gemini/gemini-cli/pull/25846)
|
||||
- (docs) update sandboxing documentation by @g-samroberts in
|
||||
[#25930](https://github.com/google-gemini/gemini-cli/pull/25930)
|
||||
- fix(core): enforce parallel task tracker updates by @anj-s in
|
||||
[#24477](https://github.com/google-gemini/gemini-cli/pull/24477)
|
||||
- Update policy so transient errors are not marked terminal by @DavidAPierce in
|
||||
[#26066](https://github.com/google-gemini/gemini-cli/pull/26066)
|
||||
- Implement bot that performs time-series metric analysis and suggests repo
|
||||
management improvements by @gundermanc in
|
||||
[#25945](https://github.com/google-gemini/gemini-cli/pull/25945)
|
||||
- fix(core): handle non-string model flags in resolution by @Adib234 in
|
||||
[#26069](https://github.com/google-gemini/gemini-cli/pull/26069)
|
||||
- fix(ux): added error message for ENOTDIR by @devr0306 in
|
||||
[#26128](https://github.com/google-gemini/gemini-cli/pull/26128)
|
||||
- Changelog for v0.40.0-preview.3 by @gemini-cli-robot in
|
||||
[#25904](https://github.com/google-gemini/gemini-cli/pull/25904)
|
||||
- fix(cli): prevent ACP stdout pollution from SessionEnd hooks by @cocosheng-g
|
||||
in [#26125](https://github.com/google-gemini/gemini-cli/pull/26125)
|
||||
- feat(cli): support boolean and number casting for env vars in settings.json by
|
||||
@cocosheng-g in
|
||||
[#26118](https://github.com/google-gemini/gemini-cli/pull/26118)
|
||||
- fix(cli): preserve Request headers in DevTools activity logger by @Adib234 in
|
||||
[#26078](https://github.com/google-gemini/gemini-cli/pull/26078)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.40.0-preview.5...v0.41.0-preview.0
|
||||
|
||||
@@ -9,7 +9,7 @@ and parameters.
|
||||
| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
|
||||
| `gemini` | Start interactive REPL | `gemini` |
|
||||
| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
|
||||
| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
|
||||
| gemini "query" | Query and continue interactively | gemini "explain this project" |
|
||||
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`<br>`Get-Content logs.txt \| gemini` |
|
||||
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
|
||||
| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` |
|
||||
@@ -33,6 +33,7 @@ These commands are available within the interactive REPL.
|
||||
| -------------------- | ----------------------------------------------- |
|
||||
| `/skills reload` | Reload discovered skills from disk |
|
||||
| `/agents reload` | Reload the agent registry |
|
||||
| `/commands list` | List available custom slash commands |
|
||||
| `/commands reload` | Reload custom slash commands |
|
||||
| `/memory reload` | Reload context files (for example, `GEMINI.md`) |
|
||||
| `/mcp reload` | Restart and reload MCP servers |
|
||||
|
||||
+169
-42
@@ -1,18 +1,21 @@
|
||||
# Creating Agent Skills
|
||||
|
||||
This guide provides an overview of how to create your own Agent Skills to extend
|
||||
the capabilities of Gemini CLI.
|
||||
Agent Skills let you extend Gemini CLI with specialized expertise, procedural
|
||||
workflows, and task-specific resources. This guide walks you through both
|
||||
automated and manual methods for creating and organizing your skills.
|
||||
|
||||
## Getting started: The `skill-creator` skill
|
||||
## Quickstart: Create a skill with a prompt
|
||||
|
||||
The recommended way to create a new skill is to use the built-in `skill-creator`
|
||||
skill. To use it, ask Gemini CLI to create a new skill for you.
|
||||
The fastest way to create a new skill is to use the built-in `skill-creator`.
|
||||
This meta-skill guides you through designing, scaffolding, and validating your
|
||||
expertise.
|
||||
|
||||
**Example prompt:**
|
||||
Simply ask Gemini CLI to create a skill for you:
|
||||
|
||||
> "create a new skill called 'code-reviewer'"
|
||||
> "Create a new skill called 'code-reviewer' that analyzes local files for
|
||||
> common errors and style violations."
|
||||
|
||||
Gemini CLI will then use the `skill-creator` to generate the skill:
|
||||
Gemini will then:
|
||||
|
||||
1. Generate a new directory for your skill (for example, `my-new-skill/`).
|
||||
2. Create a `SKILL.md` file with the necessary YAML frontmatter (`name` and
|
||||
@@ -20,24 +23,116 @@ Gemini CLI will then use the `skill-creator` to generate the skill:
|
||||
3. Create the standard resource directories: `scripts/`, `references/`, and
|
||||
`assets/`.
|
||||
|
||||
## Manual skill creation
|
||||
Once created, you can find your new skill in `.gemini/skills/code-reviewer/`.
|
||||
|
||||
If you prefer to create skills manually:
|
||||
## Manual creation
|
||||
|
||||
1. **Create a directory** for your skill (for example, `my-new-skill/`).
|
||||
2. **Create a `SKILL.md` file** inside the new directory.
|
||||
|
||||
To add additional resources that support the skill, refer to the skill
|
||||
structure.
|
||||
### 1. Create the directory structure
|
||||
|
||||
## Skill structure
|
||||
The first step is to create the necessary folders for your skill and its
|
||||
scripts.
|
||||
|
||||
A skill is a directory containing a `SKILL.md` file at its root.
|
||||
**macOS/Linux**
|
||||
|
||||
### Folder structure
|
||||
```bash
|
||||
mkdir -p .gemini/skills/code-reviewer/scripts
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path ".gemini\skills\code-reviewer\scripts"
|
||||
```
|
||||
|
||||
### 2. Define the skill (`SKILL.md`)
|
||||
|
||||
The `SKILL.md` file defines the skill's purpose and instructions for the agent.
|
||||
Create a file at `.gemini/skills/code-reviewer/SKILL.md`.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description:
|
||||
Expertise in reviewing code changes for correctness, security, and style. Use
|
||||
when the user asks to "review" their code or a PR.
|
||||
---
|
||||
|
||||
# Code Reviewer Instructions
|
||||
|
||||
You act as a senior software engineer specialized in code quality. When this
|
||||
skill is active, you MUST:
|
||||
|
||||
1. **Analyze**: Review the provided code for logical errors, security
|
||||
vulnerabilities, and style violations.
|
||||
2. **Review**: Use the bundled `scripts/review.js` utility to perform an
|
||||
automated check.
|
||||
3. **Feedback**: Provide constructive feedback, clearly distinguishing between
|
||||
critical issues and minor improvements.
|
||||
```
|
||||
|
||||
### 3. Add the tool logic
|
||||
|
||||
Skills can bundle resources like scripts to perform deterministic tasks. Create
|
||||
a file at `.gemini/skills/code-reviewer/scripts/review.js`.
|
||||
|
||||
```javascript
|
||||
// .gemini/skills/code-reviewer/scripts/review.js
|
||||
const file = process.argv[2];
|
||||
|
||||
if (!file) {
|
||||
console.error('Usage: node review.js <file>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Reviewing ${file}...`);
|
||||
// Simple mock review logic
|
||||
setTimeout(() => {
|
||||
console.log(`Result: Success (No major issues found in ${file})`);
|
||||
}, 500);
|
||||
```
|
||||
|
||||
### 4. Test the skill
|
||||
|
||||
Gemini CLI automatically discovers skills in the `.gemini/skills` directory.
|
||||
|
||||
1. Start a new session and ask a question that triggers the skill's
|
||||
description: "Can you review index.js"
|
||||
2. Gemini identifies the request matches the `code-reviewer` description and
|
||||
asks for permission to activate it.
|
||||
3. Once you approve, Gemini executes the bundled script:
|
||||
`node .gemini/skills/code-reviewer/scripts/review.js index.js`
|
||||
|
||||
To determine whether your skill has been correctly loaded, run the command:
|
||||
|
||||
```bash
|
||||
/skills
|
||||
```
|
||||
|
||||
### 5. Optional: Share your skill
|
||||
|
||||
You can share your skills in several ways depending on your target audience.
|
||||
|
||||
- **Workspace skills**: Commit your skill to a `.gemini/skills/` directory in
|
||||
your project repository.
|
||||
- **Extensions**: Bundle your skill within a
|
||||
[Gemini CLI extension](../extensions/writing-extensions.md).
|
||||
- **Git repositories**: Share the skill directory as a standalone Git repo and
|
||||
install it using `gemini skills install <url>`.
|
||||
|
||||
---
|
||||
|
||||
## Core concepts
|
||||
|
||||
Now that you've built your first skill, let's explore the core components and
|
||||
workflows for developing more complex expertise.
|
||||
|
||||
### Skill structure
|
||||
|
||||
While a `SKILL.md` file is the only required component, we recommend the
|
||||
following structure for organizing your skill's resources:
|
||||
following structure for organizing your skill's resources.
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
@@ -47,34 +142,66 @@ my-skill/
|
||||
└── assets/ (Optional) Templates and other resources
|
||||
```
|
||||
|
||||
### `SKILL.md` file
|
||||
When a skill is activated, the model is granted access to this entire directory.
|
||||
You can instruct the model to use the tools and files found within these
|
||||
folders.
|
||||
|
||||
The `SKILL.md` file is the core of your skill. This file uses YAML frontmatter
|
||||
for metadata and Markdown for instructions. For example:
|
||||
### Metadata and triggers
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-reviewer
|
||||
description:
|
||||
Use this skill to review code. It supports both local changes and remote Pull
|
||||
Requests.
|
||||
---
|
||||
|
||||
# Code Reviewer
|
||||
|
||||
This skill guides the agent in conducting thorough code reviews.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Determine Review Target
|
||||
|
||||
- **Remote PR**: If the user gives a PR number or URL, target that remote PR.
|
||||
- **Local Changes**: If changes are local... ...
|
||||
```
|
||||
The `SKILL.md` file uses YAML frontmatter for metadata.
|
||||
|
||||
- **`name`**: A unique identifier for the skill. This should match the directory
|
||||
name.
|
||||
- **`description`**: A description of what the skill does and when Gemini should
|
||||
use it.
|
||||
- **Body**: The Markdown body of the file contains the instructions that guide
|
||||
the agent's behavior when the skill is active.
|
||||
- **`description`**: **CRITICAL.** This is how Gemini decides when to use the
|
||||
skill. Be specific about the tasks it handles and the keywords that should
|
||||
trigger it.
|
||||
|
||||
### Discovery tiers
|
||||
|
||||
Gemini CLI discovers skills from several locations, following a specific order
|
||||
of precedence (lowest to highest):
|
||||
|
||||
1. **Built-in Skills**: Included with Gemini CLI (pre-approved).
|
||||
2. **Extension Skills**: Bundled within [extensions](../extensions/).
|
||||
3. **User Skills**: `~/.gemini/skills/` or the `~/.agents/skills/` alias.
|
||||
4. **Workspace Skills**: `.gemini/skills/` or the `.agents/skills/` alias.
|
||||
|
||||
### Discovery aliases
|
||||
|
||||
You can use `.agents/skills` as an alternative to `.gemini/skills`. This alias
|
||||
is compatible with other AI agent tools following the
|
||||
[Agent Skills](https://agentskills.io) standard.
|
||||
|
||||
## Advanced development
|
||||
|
||||
Once you've built a basic skill, you can use specialized scripts and workflows
|
||||
to streamline your development process.
|
||||
|
||||
### Creation scripts
|
||||
|
||||
If you are developing a skill and want to use the same scripts the built-in
|
||||
tools use, you can find them in the core package. These scripts help automate
|
||||
the initialization, validation, and packaging of skills.
|
||||
|
||||
- **Initialize**: `node scripts/init_skill.cjs <name> --path <dir>`
|
||||
- **Validate**: `node scripts/validate_skill.cjs <path/to/skill>`
|
||||
- **Package**: `node scripts/package_skill.cjs <path/to/skill>` (Creates a
|
||||
`.skill` zip file)
|
||||
|
||||
### Linking for local development
|
||||
|
||||
If you are developing a skill in a separate directory, you can link it to your
|
||||
user skills directory for testing:
|
||||
|
||||
```bash
|
||||
gemini skills link .
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Skill best practices](./skills-best-practices.md): Learn strategies for
|
||||
building reliable and effective skills.
|
||||
- [Agent Skills overview](./skills.md): Deep dive into discovery tiers and the
|
||||
skill lifecycle.
|
||||
- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A
|
||||
quick walkthrough of triggering and using skills.
|
||||
|
||||
@@ -34,6 +34,7 @@ separator (`/` or `\`) being converted to a colon (`:`).
|
||||
> [!TIP]
|
||||
> After creating or modifying `.toml` command files, run
|
||||
> `/commands reload` to pick up your changes without restarting the CLI.
|
||||
> To see all available command files, run `/commands list`.
|
||||
|
||||
## TOML file format (v1)
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ Gemini CLI will use a locally-running **Gemma** model to make routing decisions
|
||||
reduce costs associated with hosted model usage while offering similar routing
|
||||
decision latency and quality.
|
||||
|
||||
In order to use this feature, the local Gemma model **must** be served behind a
|
||||
Gemini API and accessible via HTTP at an endpoint configured in `settings.json`.
|
||||
The easiest way to set this up is using the automated `gemini gemma setup`
|
||||
command.
|
||||
|
||||
For more details on how to configure local model routing, see
|
||||
[Local Model Routing](../core/local-model-routing.md).
|
||||
[`gemini gemma` — Local Model Routing Setup](../core/gemma-setup.md).
|
||||
|
||||
### Model selection precedence
|
||||
|
||||
|
||||
@@ -470,7 +470,8 @@ associated plan files and task trackers.
|
||||
|
||||
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
|
||||
- **Configuration:** You can customize this behavior via the `/settings` command
|
||||
(search for **Session Retention**) or in your `settings.json` file. See
|
||||
(search for **Enable Session Cleanup** or **Keep chat history**) or in your
|
||||
`settings.json` file. See
|
||||
[session retention](../cli/session-management.md#session-retention) for more
|
||||
details.
|
||||
|
||||
|
||||
+165
-48
@@ -31,6 +31,53 @@ The benefits of sandboxing include:
|
||||
- **Safety**: Reduce risk when working with untrusted code or experimental
|
||||
commands.
|
||||
|
||||
## Quickstart
|
||||
|
||||
You can enable sandboxing using a command flag, environment variable, or
|
||||
configuration file.
|
||||
|
||||
### Using the command flag
|
||||
|
||||
```bash
|
||||
gemini -s -p "analyze the code structure"
|
||||
```
|
||||
|
||||
### Using an environment variable
|
||||
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
export GEMINI_SANDBOX=true
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
$env:GEMINI_SANDBOX="true"
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
### Configuring via settings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": "docker"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable sandboxing using one of the following methods (in order of precedence):
|
||||
|
||||
1. **Command flag**: `-s` or `--sandbox`
|
||||
2. **Environment variable**:
|
||||
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
|
||||
3. **Settings file**: `"sandbox": true` in the `tools` object of your
|
||||
`settings.json` file (for example, `{"tools": {"sandbox": true}}`).
|
||||
|
||||
## Sandboxing methods
|
||||
|
||||
Your ideal method of sandboxing may differ depending on your platform and your
|
||||
@@ -43,12 +90,92 @@ Lightweight, built-in sandboxing using `sandbox-exec`.
|
||||
**Default profile**: `permissive-open` - restricts writes outside project
|
||||
directory but allows most other operations.
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
- `permissive-open` (default): Write restrictions, network allowed
|
||||
- `permissive-proxied`: Write restrictions, network via proxy
|
||||
- `restrictive-open`: Strict restrictions, network allowed
|
||||
- `restrictive-proxied`: Strict restrictions, network via proxy
|
||||
- `strict-open`: Read and write restrictions, network allowed
|
||||
- `strict-proxied`: Read and write restrictions, network via proxy
|
||||
|
||||
### 2. Container-based (Docker/Podman)
|
||||
|
||||
Cross-platform sandboxing with complete process isolation.
|
||||
Cross-platform sandboxing with complete process isolation using container
|
||||
technology. By default, it uses the `ghcr.io/google/gemini-cli:latest` image.
|
||||
|
||||
**Note**: Requires building the sandbox image locally or using a published image
|
||||
from your organization's registry.
|
||||
**Prerequisites:**
|
||||
|
||||
- Docker or Podman must be installed and running on your system.
|
||||
|
||||
**How it works (Workspace directory):**
|
||||
|
||||
Inside the sandbox container, your current working directory is mounted at the
|
||||
**exact same absolute path** as it is on your host machine. For example, if you
|
||||
run the CLI from `/Users/you/project` on your host machine, the sandbox will
|
||||
mount your local project folder and operate within `/Users/you/project` inside
|
||||
the container. This allows the AI to seamlessly read and modify your project
|
||||
files while remaining isolated from the rest of your system.
|
||||
|
||||
**Quick setup:**
|
||||
|
||||
To enable Docker sandboxing, run Gemini CLI with the sandbox flag and specify
|
||||
Docker as the provider:
|
||||
|
||||
```bash
|
||||
# Using the environment variable (Recommended)
|
||||
export GEMINI_SANDBOX=docker
|
||||
gemini -p "build the project"
|
||||
|
||||
# Or configure it permanently in your settings.json
|
||||
# {"tools": {"sandbox": "docker"}}
|
||||
```
|
||||
|
||||
**Customizing the Sandbox Image:**
|
||||
|
||||
If your project requires specific dependencies, you can specify a custom image
|
||||
name or have Gemini CLI build one for you automatically. You can use any Docker
|
||||
or Podman image as your sandbox, provided it has standard shell utilities (like
|
||||
`bash`) available.
|
||||
|
||||
**Option A: Using an existing custom image (e.g., Artifact Registry)**
|
||||
|
||||
To configure a custom image that is hosted on a registry (or built locally),
|
||||
update your `settings.json` to use an object for the sandbox configuration, or
|
||||
set the `GEMINI_SANDBOX_IMAGE` environment variable.
|
||||
|
||||
_Example: Configuring via `settings.json`_
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": {
|
||||
"command": "docker",
|
||||
"image": "us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
_Example: Configuring via environment variable_
|
||||
|
||||
```bash
|
||||
export GEMINI_SANDBOX_IMAGE="us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest"
|
||||
```
|
||||
|
||||
**Option B: Building a local custom image automatically**
|
||||
|
||||
If you prefer to define your environment as code, you can provide a Dockerfile
|
||||
and Gemini CLI will build the image automatically.
|
||||
|
||||
1. Create a `.gemini/sandbox.Dockerfile` in your project root.
|
||||
2. Ensure you have the `gh` CLI installed and authenticated (if you are using
|
||||
the default `ghcr.io/google/gemini-cli` image as a base).
|
||||
3. Run your command with the `BUILD_SANDBOX` environment variable set:
|
||||
|
||||
```bash
|
||||
BUILD_SANDBOX=1 GEMINI_SANDBOX=docker gemini -p "run my custom build"
|
||||
```
|
||||
|
||||
### 3. Windows Native Sandbox (Windows only)
|
||||
|
||||
@@ -188,59 +315,49 @@ This mechanism ensures you don't have to manually re-run commands with more
|
||||
permissive sandbox settings, while still maintaining control over what the AI
|
||||
can access.
|
||||
|
||||
## Quickstart
|
||||
### Including files outside the workspace
|
||||
|
||||
By default, the sandbox only has access to the current project workspace. If you
|
||||
need the sandbox to have permission to operate on certain files or directories
|
||||
from the local file system outside of the project workspace, you can mount them
|
||||
using the `SANDBOX_MOUNTS` environment variable.
|
||||
|
||||
Provide a comma-separated list of mount definitions in the format
|
||||
`from:to:opts`. If `to` is omitted, it defaults to the same path as `from`. If
|
||||
`opts` is omitted, it defaults to `ro` (read-only). Note that the `from` path
|
||||
must be an absolute path.
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
# Enable sandboxing with command flag
|
||||
gemini -s -p "analyze the code structure"
|
||||
export SANDBOX_MOUNTS="/path/on/host:/path/in/container:rw,/another/path:ro"
|
||||
```
|
||||
|
||||
**Use environment variable**
|
||||
## Running inside a Docker container
|
||||
|
||||
**macOS/Linux**
|
||||
If you are running Gemini CLI itself from within an official or custom Docker
|
||||
container and want to enable sandboxing, you must share the host's Docker socket
|
||||
and ensure your workspace paths align.
|
||||
|
||||
1. **Mount the Docker socket**: Map `/var/run/docker.sock` so the CLI can spawn
|
||||
sibling sandbox containers via the host's Docker daemon.
|
||||
2. **Align workspace paths**: The path to your workspace inside the container
|
||||
must exactly match the absolute path on the host. Because the sandbox
|
||||
container is spawned by the host's Docker daemon, it resolves volume mounts
|
||||
against the host file system.
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
export GEMINI_SANDBOX=true
|
||||
gemini -p "run the test suite"
|
||||
docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /absolute/path/on/host/project:/absolute/path/on/host/project \
|
||||
-w /absolute/path/on/host/project \
|
||||
-e GEMINI_SANDBOX=docker \
|
||||
ghcr.io/google/gemini-cli:latest
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
$env:GEMINI_SANDBOX="true"
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
**Configure in settings.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": "docker"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable sandboxing (in order of precedence)
|
||||
|
||||
1. **Command flag**: `-s` or `--sandbox`
|
||||
2. **Environment variable**:
|
||||
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
|
||||
3. **Settings file**: `"sandbox": true` in the `tools` object of your
|
||||
`settings.json` file (for example, `{"tools": {"sandbox": true}}`).
|
||||
|
||||
### macOS Seatbelt profiles
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
- `permissive-open` (default): Write restrictions, network allowed
|
||||
- `permissive-proxied`: Write restrictions, network via proxy
|
||||
- `restrictive-open`: Strict restrictions, network allowed
|
||||
- `restrictive-proxied`: Strict restrictions, network via proxy
|
||||
- `strict-open`: Read and write restrictions, network allowed
|
||||
- `strict-proxied`: Read and write restrictions, network via proxy
|
||||
## Advanced settings
|
||||
|
||||
### Custom sandbox flags
|
||||
|
||||
@@ -279,7 +396,7 @@ export SANDBOX_FLAGS="--flag1 --flag2=value"
|
||||
$env:SANDBOX_FLAGS="--flag1 --flag2=value"
|
||||
```
|
||||
|
||||
## Linux UID/GID handling
|
||||
### Linux UID/GID handling
|
||||
|
||||
The sandbox automatically handles user permissions on Linux. Override these
|
||||
permissions with:
|
||||
|
||||
+20
-14
@@ -158,23 +158,29 @@ they appear in the UI.
|
||||
| UI Label | Setting | Description | Default |
|
||||
| --------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits. Note: Because memory is allocated during the initial process boot, this setting is only read from the global user settings file and ignores workspace-level overrides. | `true` |
|
||||
| Ignore Local .env | `advanced.ignoreLocalEnv` | Whether to ignore generic .env files in the project directory. | `false` |
|
||||
|
||||
### Experimental
|
||||
|
||||
| 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` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models via Gemini API. | `true` |
|
||||
| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
|
||||
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
|
||||
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. Note: When using the Gemini Live backend, voice recordings are sent to Google Cloud for transcription. | `"gemini-live"` |
|
||||
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
|
||||
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `1000` |
|
||||
| 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` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Agent Skill best practices
|
||||
|
||||
Create high-quality, reliable Agent Skills by following these established design
|
||||
principles and patterns.
|
||||
|
||||
## Design for discovery
|
||||
|
||||
The most important part of a skill is its `description`. This is the only
|
||||
information the model has before activation.
|
||||
|
||||
- **Be specific**: Use keywords that are likely to appear in user prompts (for
|
||||
example, "audit," "security," "refactor," "migration").
|
||||
- **Define the trigger**: Clearly state _when_ the skill should be used (for
|
||||
example, "Use this skill when the user asks to review a PR for performance
|
||||
regressions").
|
||||
- **Avoid overlap**: Ensure your skill descriptions are distinct from one
|
||||
another and from the general capabilities of the model.
|
||||
|
||||
## Progressive disclosure
|
||||
|
||||
The "context window" is a shared resource. Use a three-level loading system to
|
||||
manage context efficiently.
|
||||
|
||||
1. **Metadata (name + description)**: Always in context (~100 words).
|
||||
2. **`SKILL.md` body**: Loaded only after the skill triggers (<5k words).
|
||||
3. **Bundled resources**: Loaded only as needed by the model.
|
||||
|
||||
**Best practice**: Keep the `SKILL.md` body focused on core procedural
|
||||
instructions. Move detailed reference material, schemas, and examples into
|
||||
separate files in a `references/` directory.
|
||||
|
||||
## Degrees of freedom
|
||||
|
||||
Match the level of instruction specificity to the task's fragility.
|
||||
|
||||
- **High freedom (text-based instructions)**: Use when multiple approaches are
|
||||
valid or decisions depend heavily on context.
|
||||
- **Medium freedom (pseudocode or scripts with parameters)**: Use when a
|
||||
preferred pattern exists but some variation is acceptable.
|
||||
- **Low freedom (specific scripts, few parameters)**: Use when operations are
|
||||
fragile and error-prone, or a specific sequence MUST be followed.
|
||||
|
||||
## Bundle resources effectively
|
||||
|
||||
Leverage the skill's ability to include scripts and assets to extend the agent's
|
||||
capabilities.
|
||||
|
||||
- **Use scripts for deterministic tasks**: If a task can be automated with a
|
||||
script (for example, running a linter, fetching data from an API), bundle it
|
||||
in the `scripts/` folder.
|
||||
- **Agentic ergonomics**: Ensure scripts output LLM-friendly stdout. Suppress
|
||||
verbose tracebacks and provide clear, concise success/failure messages.
|
||||
- **Provide templates**: Include common file headers or boilerplate code in the
|
||||
`assets/` folder to ensure the agent produces consistent output.
|
||||
|
||||
## Anatomy of a great skill
|
||||
|
||||
A well-structured skill directory organizes its resources into specialized
|
||||
sub-folders.
|
||||
|
||||
```text
|
||||
my-skill/
|
||||
├── SKILL.md (Required) Core instructions and metadata
|
||||
├── scripts/ (Optional) Executable logic (Node.js, Python, etc.)
|
||||
├── references/ (Optional) Documentation to be loaded as needed
|
||||
└── assets/ (Optional) Templates and non-executable resources
|
||||
```
|
||||
|
||||
## Security and privacy
|
||||
|
||||
Design your skills with security in mind to protect your workspace and data.
|
||||
|
||||
- **Avoid hardcoded secrets**: Never include API keys or passwords in your
|
||||
skill's scripts or documentation.
|
||||
- **Review third-party skills**: Inspect the `SKILL.md` and scripts of any skill
|
||||
before installing it from an untrusted source.
|
||||
- **Limit scope**: Design skills to be as focused as possible to minimize the
|
||||
potential impact of errors.
|
||||
+113
-108
@@ -1,112 +1,21 @@
|
||||
# Agent Skills
|
||||
|
||||
Agent Skills allow you to extend Gemini CLI with specialized expertise,
|
||||
procedural workflows, and task-specific resources. Based on the
|
||||
Agent Skills let you extend Gemini CLI with specialized expertise, procedural
|
||||
workflows, and task-specific resources. Based on the
|
||||
[Agent Skills](https://agentskills.io) open standard, a "skill" is a
|
||||
self-contained directory that packages instructions and assets into a
|
||||
discoverable capability.
|
||||
|
||||
## Overview
|
||||
|
||||
Unlike general context files ([`GEMINI.md`](./gemini-md.md)), which provide
|
||||
Unlike general context files ([GEMINI.md](./gemini-md.md)), which provide
|
||||
persistent workspace-wide background, Skills represent **on-demand expertise**.
|
||||
This allows Gemini to maintain a vast library of specialized capabilities—such
|
||||
as security auditing, cloud deployments, or codebase migrations—without
|
||||
cluttering the model's immediate context window.
|
||||
This lets Gemini CLI maintain a vast library of specialized capabilities—such as
|
||||
security auditing, cloud deployments, or codebase migrations—without cluttering
|
||||
the model's immediate context window.
|
||||
|
||||
Gemini autonomously decides when to employ a skill based on your request and the
|
||||
skill's description. When a relevant skill is identified, the model "pulls in"
|
||||
the full instructions and resources required to complete the task using the
|
||||
`activate_skill` tool.
|
||||
## How it works
|
||||
|
||||
## Key Benefits
|
||||
|
||||
- **Shared Expertise:** Package complex workflows (like a specific team's PR
|
||||
review process) into a folder that anyone can use.
|
||||
- **Repeatable Workflows:** Ensure complex multi-step tasks are performed
|
||||
consistently by providing a procedural framework.
|
||||
- **Resource Bundling:** Include scripts, templates, or example data alongside
|
||||
instructions so the agent has everything it needs.
|
||||
- **Progressive Disclosure:** Only skill metadata (name and description) is
|
||||
loaded initially. Detailed instructions and resources are only disclosed when
|
||||
the model explicitly activates the skill, saving context tokens.
|
||||
|
||||
## Skill Discovery Tiers
|
||||
|
||||
Gemini CLI discovers skills from three primary locations:
|
||||
|
||||
1. **Workspace Skills**: Located in `.gemini/skills/` or the `.agents/skills/`
|
||||
alias. Workspace skills are typically committed to version control and
|
||||
shared with the team.
|
||||
2. **User Skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/`
|
||||
alias. These are personal skills available across all your workspaces.
|
||||
3. **Extension Skills**: Skills bundled within installed
|
||||
[extensions](../extensions/index.md).
|
||||
|
||||
**Precedence:** If multiple skills share the same name, higher-precedence
|
||||
locations override lower ones: **Workspace > User > Extension**.
|
||||
|
||||
Within the same tier (user or workspace), the `.agents/skills/` alias takes
|
||||
precedence over the `.gemini/skills/` directory. This generic alias provides an
|
||||
intuitive path for managing agent-specific expertise that remains compatible
|
||||
across different AI agent tools.
|
||||
|
||||
## Managing Skills
|
||||
|
||||
### In an Interactive Session
|
||||
|
||||
Use the `/skills` slash command to view and manage available expertise:
|
||||
|
||||
- `/skills list` (default): Shows all discovered skills and their status.
|
||||
- `/skills link <path>`: Links agent skills from a local directory via symlink.
|
||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> `/skills disable` and `/skills enable` default to the `user` scope. Use
|
||||
> `--scope workspace` to manage workspace-specific settings.
|
||||
|
||||
### From the Terminal
|
||||
|
||||
The `gemini skills` command provides management utilities:
|
||||
|
||||
```bash
|
||||
# List all discovered skills
|
||||
gemini skills list
|
||||
|
||||
# Link agent skills from a local directory via symlink
|
||||
# Discovers skills (SKILL.md or */SKILL.md) and creates symlinks in ~/.gemini/skills
|
||||
# (or ~/.agents/skills)
|
||||
gemini skills link /path/to/my-skills-repo
|
||||
|
||||
# Link to the workspace scope (.gemini/skills or .agents/skills)
|
||||
gemini skills link /path/to/my-skills-repo --scope workspace
|
||||
|
||||
# Install a skill from a Git repository, local directory, or zipped skill file (.skill)
|
||||
# Uses the user scope by default (~/.gemini/skills or ~/.agents/skills)
|
||||
gemini skills install https://github.com/user/repo.git
|
||||
gemini skills install /path/to/local/skill
|
||||
gemini skills install /path/to/local/my-expertise.skill
|
||||
|
||||
# Install a specific skill from a monorepo or subdirectory using --path
|
||||
gemini skills install https://github.com/my-org/my-skills.git --path skills/frontend-design
|
||||
|
||||
# Install to the workspace scope (.gemini/skills or .agents/skills)
|
||||
gemini skills install /path/to/skill --scope workspace
|
||||
|
||||
# Uninstall a skill by name
|
||||
gemini skills uninstall my-expertise --scope workspace
|
||||
|
||||
# Enable a skill (globally)
|
||||
gemini skills enable my-expertise
|
||||
|
||||
# Disable a skill. Can use --scope to specify workspace or user (defaults to workspace)
|
||||
gemini skills disable my-expertise --scope workspace
|
||||
```
|
||||
|
||||
## How it Works
|
||||
The lifecycle of an Agent Skill involves discovery, activation, and conditional
|
||||
resource access.
|
||||
|
||||
1. **Discovery**: At the start of a session, Gemini CLI scans the discovery
|
||||
tiers and injects the name and description of all enabled skills into the
|
||||
@@ -123,14 +32,110 @@ gemini skills disable my-expertise --scope workspace
|
||||
5. **Execution**: The model proceeds with the specialized expertise active. It
|
||||
is instructed to prioritize the skill's procedural guidance within reason.
|
||||
|
||||
### Skill activation
|
||||
## Discovery tiers
|
||||
|
||||
Once a skill is activated (typically by Gemini identifying a task that matches
|
||||
the skill's description and your approval), its specialized instructions and
|
||||
resources are loaded into the agent's context. A skill remains active and its
|
||||
guidance is prioritized for the duration of the session.
|
||||
Gemini CLI discovers skills from several locations, following a specific order
|
||||
of precedence (lowest to highest):
|
||||
|
||||
## Creating your own skills
|
||||
1. **Built-in skills**: Standard skills included with Gemini CLI that provide
|
||||
foundational capabilities.
|
||||
2. **Extension skills**: Skills bundled within installed
|
||||
[extensions](../extensions/index.md).
|
||||
3. **User skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/`
|
||||
alias.
|
||||
4. **Workspace skills**: Located in `.gemini/skills/` or the `.agents/skills/`
|
||||
alias. Workspace skills are shared with your team via version control.
|
||||
|
||||
To create your own skills, see the [Create Agent Skills](./creating-skills.md)
|
||||
guide.
|
||||
### Precedence and aliases
|
||||
|
||||
If multiple skills share the same name, the version from the higher-precedence
|
||||
location is used. Within the same tier (user or workspace), the
|
||||
`.agents/skills/` alias takes precedence over the `.gemini/skills/` directory.
|
||||
|
||||
The `.agents/skills/` alias provides an interoperable path for managing
|
||||
agent-specific expertise that remains compatible across different AI tools.
|
||||
|
||||
## Key benefits
|
||||
|
||||
Agent Skills provide several advantages for managing specialized knowledge and
|
||||
complex workflows.
|
||||
|
||||
- **Shared expertise**: Package complex workflows (like a specific team's PR
|
||||
review process) into a folder that anyone can use.
|
||||
- **Repeatable workflows**: Ensure complex multi-step tasks are performed
|
||||
consistently by providing a procedural framework.
|
||||
- **Resource bundling**: Include scripts, templates, or example data alongside
|
||||
instructions so the agent has everything it needs.
|
||||
- **Progressive disclosure**: Only skill metadata (name and description) is
|
||||
loaded initially. Detailed instructions and resources are only disclosed when
|
||||
the model explicitly activates the skill, saving context tokens.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> `/skills disable` and `/skills enable` default to the `user` scope. Use
|
||||
> `--scope workspace` to manage workspace-specific settings.
|
||||
|
||||
To see all available skills in your current session, use the `/skills list`
|
||||
command.
|
||||
|
||||
## Managing skills
|
||||
|
||||
You can manage Agent Skills through interactive session commands or directly
|
||||
from your terminal.
|
||||
|
||||
### In an interactive session
|
||||
|
||||
Use the `/skills` slash command to view and manage available expertise:
|
||||
|
||||
- `/skills list [all] [nodesc]`: Shows discovered skills. Use `all` to include
|
||||
built-in skills and `nodesc` to hide descriptions.
|
||||
- `/skills link <path> [--scope user|workspace]`: Links skills from a local
|
||||
directory.
|
||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- `/skills reload` (or `/skills refresh`): Refreshes the list of discovered
|
||||
skills from all tiers.
|
||||
|
||||
### From the terminal
|
||||
|
||||
The `gemini skills` command provides management utilities:
|
||||
|
||||
```bash
|
||||
# List all discovered skills. Use --all to include built-in skills.
|
||||
gemini skills list --all
|
||||
|
||||
# Install a skill from a Git repository or local directory.
|
||||
# Use --consent to skip the security confirmation prompt.
|
||||
gemini skills install https://github.com/user/repo.git --consent
|
||||
|
||||
# Uninstall a skill.
|
||||
gemini skills uninstall my-skill --scope workspace
|
||||
```
|
||||
|
||||
#### Command options
|
||||
|
||||
The skill management commands support several global and command-specific
|
||||
options.
|
||||
|
||||
- `--scope`: Either `user` (global, default) or `workspace` (local to the
|
||||
project).
|
||||
- `--path`: The sub-directory within a Git repository containing the skill.
|
||||
- `--consent`: Acknowledge security risks and skip the interactive confirmation
|
||||
during installation.
|
||||
|
||||
For more details on CLI commands, see the
|
||||
[CLI reference](./cli-reference.md#skills-management).
|
||||
|
||||
## Next steps
|
||||
|
||||
Explore these resources to refine your skills and understand the framework
|
||||
better.
|
||||
|
||||
- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A
|
||||
quick walkthrough of triggering and using skills.
|
||||
- [Creating Agent Skills](./creating-skills.md): Create your first skill and
|
||||
bundle custom logic.
|
||||
- [Using Agent Skills](./using-agent-skills.md): Learn how to leverage built-in
|
||||
and custom skills.
|
||||
- [Best practices](./skills-best-practices.md): Learn strategies for building
|
||||
effective skills.
|
||||
|
||||
@@ -61,6 +61,19 @@ gemini --list-sessions
|
||||
gemini --delete-session 1
|
||||
```
|
||||
|
||||
### Scenario: Delete session on exit
|
||||
|
||||
If you're doing a one-off task and don't want to leave any session history
|
||||
behind, use the `--delete` flag when exiting:
|
||||
|
||||
```
|
||||
/exit --delete
|
||||
```
|
||||
|
||||
This removes the current session's conversation history and tool output files
|
||||
before exiting. It's useful for privacy-sensitive tasks or quick one-off
|
||||
interactions.
|
||||
|
||||
## How to rewind time (Undo mistakes)
|
||||
|
||||
Gemini CLI's **Rewind** feature is like `Ctrl+Z` for your workflow.
|
||||
|
||||
@@ -1,110 +1,158 @@
|
||||
# Get started with Agent Skills
|
||||
|
||||
Agent Skills extend Gemini CLI with specialized expertise. In this guide, you'll
|
||||
learn how to create your first skill, bundle custom scripts, and activate them
|
||||
during a session.
|
||||
Agent Skills extend Gemini CLI with specialized expertise. In this tutorial,
|
||||
you'll learn how to create your first skill, bundle custom logic, and activate
|
||||
it during a session.
|
||||
|
||||
## How to create a skill
|
||||
## Create your first skill
|
||||
|
||||
A skill is defined by a directory containing a `SKILL.md` file. Let's create an
|
||||
**API Auditor** skill that helps you verify if local or remote endpoints are
|
||||
responding correctly.
|
||||
A skill is defined by a directory containing a `SKILL.md` file and
|
||||
subdirectories containing reference materials or scripts used by the skill.
|
||||
Let's create an **API Auditor** skill that runs a script to help you verify if
|
||||
local or remote endpoints are responding correctly.
|
||||
|
||||
### Create the directory structure
|
||||
### 1. Create the directory structure
|
||||
|
||||
1. Run the following command to create the folders:
|
||||
The first step is to create the necessary folders for your skill and its
|
||||
scripts.
|
||||
|
||||
**macOS/Linux**
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
mkdir -p .gemini/skills/api-auditor/scripts
|
||||
```
|
||||
```bash
|
||||
mkdir -p .gemini/skills/api-auditor/scripts
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path ".gemini\skills\api-auditor\scripts"
|
||||
```
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path ".gemini\skills\api-auditor\scripts"
|
||||
```
|
||||
|
||||
### Create the definition
|
||||
### 2. Create the definition (`SKILL.md`)
|
||||
|
||||
1. Create a file at `.gemini/skills/api-auditor/SKILL.md`. This tells the agent
|
||||
_when_ to use the skill and _how_ to behave.
|
||||
The `SKILL.md` file defines the skill's purpose and instructions for the agent.
|
||||
Create a file at `.gemini/skills/api-auditor/SKILL.md`. This tells the agent
|
||||
_when_ to use the skill and _how_ to behave.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: api-auditor
|
||||
description:
|
||||
Expertise in auditing and testing API endpoints. Use when the user asks to
|
||||
"check", "test", or "audit" a URL or API.
|
||||
---
|
||||
```markdown
|
||||
---
|
||||
name: api-auditor
|
||||
description:
|
||||
Expertise in auditing and testing API endpoints. Use when the user asks to
|
||||
"check", "test", or "audit" a URL or API.
|
||||
---
|
||||
|
||||
# API Auditor Instructions
|
||||
# API Auditor Instructions
|
||||
|
||||
You act as a QA engineer specialized in API reliability. When this skill is
|
||||
active, you MUST:
|
||||
You act as a QA engineer specialized in API reliability. When this skill is
|
||||
active, you MUST:
|
||||
|
||||
1. **Audit**: Use the bundled `scripts/audit.js` utility to check the
|
||||
status of the provided URL.
|
||||
2. **Report**: Analyze the output (status codes, latency) and explain any
|
||||
failures in plain English.
|
||||
3. **Secure**: Remind the user if they are testing a sensitive endpoint
|
||||
without an `https://` protocol.
|
||||
```
|
||||
1. **Audit**: Use the bundled `scripts/audit.js` utility to check the status of
|
||||
the provided URL.
|
||||
2. **Report**: Analyze the output (status codes, latency) and explain any
|
||||
failures in plain English.
|
||||
3. **Secure**: Remind the user if they are testing a sensitive endpoint without
|
||||
an `https://` protocol.
|
||||
```
|
||||
|
||||
### Add the tool logic
|
||||
### 3. Add the tool logic
|
||||
|
||||
Skills can bundle resources like scripts.
|
||||
Skills can bundle resources like scripts to perform deterministic tasks. Create
|
||||
a file at `.gemini/skills/api-auditor/scripts/audit.js`. This is the code the
|
||||
agent will run.
|
||||
|
||||
1. Create a file at `.gemini/skills/api-auditor/scripts/audit.js`. This is the
|
||||
code the agent will run.
|
||||
```javascript
|
||||
// .gemini/skills/api-auditor/scripts/audit.js
|
||||
const url = process.argv[2];
|
||||
|
||||
```javascript
|
||||
// .gemini/skills/api-auditor/scripts/audit.js
|
||||
const url = process.argv[2];
|
||||
if (!url) {
|
||||
console.error('Usage: node audit.js <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
console.error('Usage: node audit.js <url>');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Auditing ${url}...`);
|
||||
fetch(url, { method: 'HEAD' })
|
||||
.then((r) => console.log(`Result: Success (Status ${r.status})`))
|
||||
.catch((e) => console.error(`Result: Failed (${e.message})`));
|
||||
```
|
||||
|
||||
console.log(`Auditing ${url}...`);
|
||||
fetch(url, { method: 'HEAD' })
|
||||
.then((r) => console.log(`Result: Success (Status ${r.status})`))
|
||||
.catch((e) => console.error(`Result: Failed (${e.message})`));
|
||||
```
|
||||
## Verify discovery
|
||||
|
||||
## How to verify discovery
|
||||
Gemini CLI automatically discovers skills in the `.gemini/skills` directory (as
|
||||
well as the `.agents/skills` alias).
|
||||
|
||||
Gemini CLI automatically discovers skills in the `.gemini/skills` directory. You
|
||||
can also use `.agents/skills` as a more generic alternative. Check that it found
|
||||
your new skill.
|
||||
To check if Gemini CLI found your new skill, use the `/skills list` command
|
||||
within an interactive session:
|
||||
|
||||
**Command:** `/skills list`
|
||||
```bash
|
||||
/skills list
|
||||
```
|
||||
|
||||
You should see `api-auditor` in the list of available skills.
|
||||
You should see `api-auditor` in the list of available skills. If you just added
|
||||
the files, you can run `/skills reload` to refresh the list without restarting
|
||||
the session.
|
||||
|
||||
### If your skill doesn't appear
|
||||
|
||||
If `/skills list` doesn't show your skill, check the following:
|
||||
|
||||
1. **The folder must be trusted (workspace skills only).** Skills under
|
||||
`<workspace>/.gemini/skills/` are only loaded when the workspace folder is
|
||||
marked as trusted. Run `/trust` and restart the session if needed. Skills
|
||||
under `~/.gemini/skills/` (user scope) are not affected by trust.
|
||||
2. **Check the path layout.** `SKILL.md` is discovered either at the root of
|
||||
the skills directory (`.gemini/skills/SKILL.md`) or one directory deep
|
||||
(`.gemini/skills/<skill-name>/SKILL.md`). The recommended layout uses a
|
||||
subdirectory per skill so you can bundle scripts and other resources
|
||||
alongside it. Files nested more than one directory deep are not discovered.
|
||||
3. **The filename must be exactly `SKILL.md`.** Capitalization matters on
|
||||
case-sensitive filesystems (Linux, and macOS when configured as such):
|
||||
`skill.md` or `Skill.md` will be ignored.
|
||||
4. **Frontmatter must include both `name:` and `description:`, and must be the
|
||||
first thing in the file.** A `SKILL.md` is silently skipped if either field
|
||||
is missing, if the delimiters (`---` on their own lines) are absent, or if
|
||||
any text (an H1 title, a comment, even a blank line) appears before the
|
||||
opening `---`.
|
||||
5. **The skill name comes from the `name:` field, not the directory name.** If
|
||||
your frontmatter says `name: foo`, the skill appears as `foo` in
|
||||
`/skills list` regardless of what its parent directory is called. The
|
||||
characters `: \ / < > * ? " |` in the name are replaced with `-`.
|
||||
|
||||
## How to use the skill
|
||||
|
||||
Now, try it out. Start a new session and ask a question that triggers the
|
||||
skill's description.
|
||||
Now that the skill is discovered, you can trigger its activation by asking a
|
||||
relevant question.
|
||||
|
||||
**User:** "Can you audit http://geminicli.com"
|
||||
1. **Trigger**: Start a new session and ask: "Can you audit https://google.com"
|
||||
2. **Activation**: Gemini identifies that the request matches the `api-auditor`
|
||||
description and calls the `activate_skill` tool.
|
||||
3. **Consent**: You will see a confirmation prompt. Type **y** to approve.
|
||||
4. **Execution**: Once activated, Gemini uses the `run_shell_command` tool to
|
||||
execute your bundled script:
|
||||
`node .gemini/skills/api-auditor/scripts/audit.js https://google.com`
|
||||
|
||||
Gemini recognizes the request matches the `api-auditor` description and asks for
|
||||
permission to activate it.
|
||||
## Pro tip: Use the skill-creator
|
||||
|
||||
**Model:** (After calling `activate_skill`) "I've activated the **api-auditor**
|
||||
skill. I'll run the audit script now..."
|
||||
If you don't want to create the files manually, you can use the built-in
|
||||
`skill-creator` skill. Simply ask Gemini:
|
||||
|
||||
Gemini then uses the `run_shell_command` tool to execute your bundled Node
|
||||
script:
|
||||
> "Create a new skill called 'api-auditor' that tests if URLs are responding."
|
||||
|
||||
`node .gemini/skills/api-auditor/scripts/audit.js http://geminili.com`
|
||||
The `skill-creator` will handle the directory structure and boilerplate for you.
|
||||
|
||||
## Manage skills
|
||||
|
||||
You can also manage skills using the `gemini skills` command from your terminal:
|
||||
|
||||
- **Install**: `gemini skills install <url-or-path>`
|
||||
- **Link**: `gemini skills link <path>` (useful for local development)
|
||||
- **Uninstall**: `gemini skills uninstall <name>`
|
||||
|
||||
## Next steps
|
||||
|
||||
- Explore the
|
||||
[Agent Skills Authoring Guide](../../cli/skills.md#creating-a-skill) to learn
|
||||
about more advanced features.
|
||||
- Learn how to share skills via [Extensions](../../extensions/index.md).
|
||||
- [Creating Agent Skills](../creating-skills.md): Detailed guide on advanced
|
||||
skill features and metadata.
|
||||
- [Using Agent Skills](../using-agent-skills.md): More ways to discover and
|
||||
manage your skill library.
|
||||
- [Skill best practices](../skills-best-practices.md): Learn how to design
|
||||
reliable and effective expertise.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Managing Agent Skills
|
||||
|
||||
Agent Skills provide Gemini CLI with specialized expertise on demand. This guide
|
||||
covers advanced management techniques, including using slash commands, terminal
|
||||
utilities, and understanding discovery tiers.
|
||||
|
||||
## Discovery tiers
|
||||
|
||||
Gemini CLI discovers skills from several locations, following a specific order
|
||||
of precedence (lowest to highest):
|
||||
|
||||
1. **Built-in Skills**: Included with Gemini CLI and always available.
|
||||
2. **Extension Skills**: Bundled within [extensions](../extensions/index.md).
|
||||
3. **User Skills**: Located in `~/.gemini/skills/` or the `~/.agents/skills/`
|
||||
alias. These are available across all your projects.
|
||||
4. **Workspace Skills**: Located in `.gemini/skills/` or the `.agents/skills/`
|
||||
alias within your current directory. These are project-specific.
|
||||
|
||||
> **Tip:** If multiple skills share the same name, the version from the
|
||||
> higher-precedence location is used.
|
||||
|
||||
## In-session management
|
||||
|
||||
Use the `/skills` slash command during an interactive session to manage your
|
||||
available expertise.
|
||||
|
||||
- **List skills**: `/skills list` (shows discovered skills).
|
||||
- Use `/skills list all` to include internal built-in skills.
|
||||
- Use `/skills list nodesc` to hide descriptions.
|
||||
- **Reload skills**: `/skills reload` (or `/skills refresh`) to scan for new or
|
||||
modified skills without restarting the CLI.
|
||||
- **Toggle status**:
|
||||
- `/skills disable <name>`: Prevents a skill from being triggered.
|
||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||
- **Link local skills**: `/skills link <path> [--scope user|workspace]` to
|
||||
immediately use a skill you are developing.
|
||||
|
||||
## Terminal utilities
|
||||
|
||||
The `gemini skills` command provides management utilities directly from your
|
||||
system shell.
|
||||
|
||||
### Install a skill
|
||||
|
||||
To install a skill from a remote repository or a local `.skill` package:
|
||||
|
||||
```bash
|
||||
gemini skills install https://github.com/user/my-awesome-skill
|
||||
```
|
||||
|
||||
By default, this installs to your **user profile**. Use `--scope workspace` to
|
||||
install it only for the current project.
|
||||
|
||||
### Link for development
|
||||
|
||||
If you are developing a skill, use the `link` command to create a reference to
|
||||
your local directory:
|
||||
|
||||
```bash
|
||||
gemini skills link ./path/to/my-skill
|
||||
```
|
||||
|
||||
### Uninstall a skill
|
||||
|
||||
To completely remove an installed or linked skill:
|
||||
|
||||
```bash
|
||||
gemini skills uninstall <name>
|
||||
```
|
||||
|
||||
## Security and consent
|
||||
|
||||
Agent Skills can execute scripts and access your files. To protect your
|
||||
environment:
|
||||
|
||||
1. **Installation consent**: When installing from a remote URL, you will be
|
||||
asked to confirm the source.
|
||||
2. **Activation consent**: Every time a skill is triggered during a session,
|
||||
the agent must ask for permission to activate it and gain access to its
|
||||
resources.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Get started with Agent Skills](./tutorials/skills-getting-started.md): A
|
||||
walkthrough for creating your first skill.
|
||||
- [Creating Agent Skills](./creating-skills.md): Detailed guide on bundling
|
||||
scripts and assets.
|
||||
- [Skill best practices](./skills-best-practices.md): Strategies for building
|
||||
reliable expertise.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `gemini gemma` — Automated Local Model Routing Setup
|
||||
|
||||
Local model routing uses a local Gemma 3 1B model running on your machine to
|
||||
classify and route user requests. It routes simple requests (like file reads) to
|
||||
Gemini Flash and complex requests (like architecture discussions) to Gemini Pro.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> This is an experimental feature currently under active development.
|
||||
|
||||
## What is this?
|
||||
|
||||
This feature saves cloud API costs by using local inference for task
|
||||
classification instead of a cloud-based classifier. It adds a few milliseconds
|
||||
of local latency but can significantly reduce the overall token usage for hosted
|
||||
models.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# One command does everything: downloads runtime, pulls model, configures settings, starts server
|
||||
gemini gemma setup
|
||||
```
|
||||
|
||||
You'll be prompted to accept the Gemma Terms of Use. The model is ~1 GB.
|
||||
|
||||
After setup, **just use the CLI normally** — routing happens automatically on
|
||||
every request.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
| --------------------- | -------------------------------------------------------------- |
|
||||
| `gemini gemma setup` | Full install (binary + model + settings + server start) |
|
||||
| `gemini gemma status` | Health check — shows what's installed and running |
|
||||
| `gemini gemma start` | Start the LiteRT server (auto-starts on CLI launch by default) |
|
||||
| `gemini gemma stop` | Stop the LiteRT server |
|
||||
| `gemini gemma logs` | Tail the server logs to see routing requests live |
|
||||
| `/gemma` | In-session status check (type it inside the CLI) |
|
||||
|
||||
## Verifying it works
|
||||
|
||||
1. Run `gemini gemma status` — all checks should show green
|
||||
2. Open two terminals:
|
||||
- Terminal 1: `gemini gemma logs` (watch for incoming requests)
|
||||
- Terminal 2: use the CLI normally
|
||||
3. You should see classification requests appear in the logs as you interact
|
||||
with the CLI
|
||||
4. The `/gemma` slash command inside a session shows a quick status panel
|
||||
|
||||
## Setup flags
|
||||
|
||||
```bash
|
||||
gemini gemma setup --port 8080 # custom port
|
||||
gemini gemma setup --no-start # don't start server after install
|
||||
gemini gemma setup --force # re-download everything
|
||||
gemini gemma setup --skip-model # binary only, skip the 1GB model download
|
||||
```
|
||||
|
||||
## How it works under the hood
|
||||
|
||||
- Local Gemma classifies each request as "simple" or "complex" (~100ms)
|
||||
- Simple → Flash, Complex → Pro
|
||||
- If the local server is down, the CLI silently falls back to the cloud
|
||||
classifier — no errors, no disruption
|
||||
|
||||
## Disabling
|
||||
|
||||
Set `enabled: false` in settings or just run `gemini gemma stop` to turn off the
|
||||
server:
|
||||
|
||||
```json
|
||||
{ "experimental": { "gemmaModelRouter": { "enabled": false } } }
|
||||
```
|
||||
|
||||
## Advanced setup
|
||||
|
||||
If you are in an environment where the `gemini gemma setup` command cannot
|
||||
automatically download binaries (for example, behind a strict corporate
|
||||
firewall), you can perform the setup manually.
|
||||
|
||||
For more information, see the
|
||||
[Manual Local Model Routing Setup guide](./local-model-routing.md).
|
||||
+3
-2
@@ -15,8 +15,9 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
|
||||
modular GEMINI.md import feature using @file.md syntax.
|
||||
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
|
||||
fine-grained control over tool execution.
|
||||
- **[Local Model Routing (experimental)](./local-model-routing.md):** Learn how
|
||||
to enable use of a local Gemma model for model routing decisions.
|
||||
- **[Local Model Routing (experimental)](./gemma-setup.md):** Learn how to
|
||||
enable use of a local Gemma model for model routing decisions using the
|
||||
automated setup command.
|
||||
|
||||
## Role of the core
|
||||
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
# Local Model Routing (experimental)
|
||||
# Manual Local Model Routing Setup (experimental)
|
||||
|
||||
Gemini CLI supports using a local model for
|
||||
[routing decisions](../cli/model-routing.md). When configured, Gemini CLI will
|
||||
use a locally-running **Gemma** model to make routing decisions (instead of
|
||||
sending routing decisions to a hosted model).
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!NOTE]
|
||||
> This is an experimental feature currently under active development.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!IMPORTANT]
|
||||
> **Recommended:** We now provide a fully automated setup command. We recommend
|
||||
> using the [`gemini gemma` Setup Guide](./gemma-setup.md) instead of following
|
||||
> these manual steps.
|
||||
|
||||
This feature can help reduce costs associated with hosted model usage while
|
||||
offering similar routing decision latency and quality.
|
||||
|
||||
> **Note: Local model routing is currently an experimental feature.**
|
||||
|
||||
## Setup
|
||||
## Manual Setup
|
||||
|
||||
Using a Gemma model for routing decisions requires that an implementation of a
|
||||
Gemma model be running locally on your machine, served behind an HTTP endpoint
|
||||
and accessed via the Gemini API.
|
||||
|
||||
To serve the Gemma model, follow these steps:
|
||||
and accessed via the Gemini API. If you cannot use the `gemini gemma setup`
|
||||
command, follow these manual steps:
|
||||
|
||||
### Download the LiteRT-LM runtime
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
- **Description:** Manage custom slash commands loaded from `.toml` files.
|
||||
- **Sub-commands:**
|
||||
- **`list`**:
|
||||
- **Description:** List available custom command `.toml` files from all
|
||||
sources (user-level `~/.gemini/commands/`, project-level
|
||||
`<project>/.gemini/commands/`, and active extensions).
|
||||
- **Usage:** `/commands list`
|
||||
- **`reload`**:
|
||||
- **Description:** Reload custom command definitions from all sources
|
||||
(user-level `~/.gemini/commands/`, project-level
|
||||
@@ -323,6 +328,11 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
### `/quit` (or `/exit`)
|
||||
|
||||
- **Description:** Exit Gemini CLI.
|
||||
- **Flags:**
|
||||
- **`--delete`** _(optional)_: Exit and permanently delete the current
|
||||
session's history and temporary files (chat recording, tool outputs). Useful
|
||||
for privacy or one-off tasks where you don't want to leave any traces.
|
||||
- **Usage:** `/quit --delete` or `/exit --delete`
|
||||
|
||||
### `/restore`
|
||||
|
||||
@@ -499,12 +509,12 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
|
||||
These shortcuts apply directly to the input prompt for text manipulation.
|
||||
|
||||
- **Undo:**
|
||||
- **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
|
||||
in the input prompt.
|
||||
- **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
|
||||
**Alt+z** (Linux/WSL) to undo the last action in the input prompt.
|
||||
|
||||
- **Redo:**
|
||||
- **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
|
||||
last undone action in the input prompt.
|
||||
- **Keyboard shortcut:** Press **Shift+Cmd+Z** (macOS), or **Shift+Alt+Z**
|
||||
(Linux/WSL) to redo the last undone action in the input prompt.
|
||||
|
||||
## At commands (`@`)
|
||||
|
||||
|
||||
@@ -1199,6 +1199,42 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"isLastResort": true,
|
||||
"maxAttempts": 10,
|
||||
"actions": {
|
||||
"terminal": "prompt",
|
||||
"transient": "prompt",
|
||||
"not_found": "prompt",
|
||||
"unknown": "prompt"
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
}
|
||||
],
|
||||
"auto-preview": [
|
||||
{
|
||||
"model": "gemini-3-pro-preview",
|
||||
"maxAttempts": 3,
|
||||
"actions": {
|
||||
"terminal": "prompt",
|
||||
"transient": "silent",
|
||||
"not_found": "prompt",
|
||||
"unknown": "prompt"
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"isLastResort": true,
|
||||
"maxAttempts": 10,
|
||||
"actions": {
|
||||
"terminal": "prompt",
|
||||
"transient": "prompt",
|
||||
@@ -1224,7 +1260,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1232,6 +1268,42 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
{
|
||||
"model": "gemini-2.5-flash",
|
||||
"isLastResort": true,
|
||||
"maxAttempts": 10,
|
||||
"actions": {
|
||||
"terminal": "prompt",
|
||||
"transient": "prompt",
|
||||
"not_found": "prompt",
|
||||
"unknown": "prompt"
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
}
|
||||
],
|
||||
"auto-default": [
|
||||
{
|
||||
"model": "gemini-2.5-pro",
|
||||
"maxAttempts": 3,
|
||||
"actions": {
|
||||
"terminal": "prompt",
|
||||
"transient": "silent",
|
||||
"not_found": "prompt",
|
||||
"unknown": "prompt"
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "gemini-2.5-flash",
|
||||
"isLastResort": true,
|
||||
"maxAttempts": 10,
|
||||
"actions": {
|
||||
"terminal": "prompt",
|
||||
"transient": "prompt",
|
||||
@@ -1680,6 +1752,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
["DEBUG", "DEBUG_MODE"]
|
||||
```
|
||||
|
||||
- **`advanced.ignoreLocalEnv`** (boolean):
|
||||
- **Description:** Whether to ignore generic .env files in the project
|
||||
directory.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`advanced.bugCommand`** (object):
|
||||
- **Description:** Configuration for the bug report command.
|
||||
- **Default:** `undefined`
|
||||
@@ -1687,10 +1765,38 @@ 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`
|
||||
- **Description:** Enable access to Gemma 4 models via Gemini API.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.voiceMode`** (boolean):
|
||||
- **Description:** Enable experimental voice dictation and commands (/voice,
|
||||
/voice model).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.voice.activationMode`** (enum):
|
||||
- **Description:** How to trigger voice recording with the Space key.
|
||||
- **Default:** `"push-to-talk"`
|
||||
- **Values:** `"push-to-talk"`, `"toggle"`
|
||||
|
||||
- **`experimental.voice.backend`** (enum):
|
||||
- **Description:** The backend to use for voice transcription. Note: When
|
||||
using the Gemini Live backend, voice recordings are sent to Google Cloud for
|
||||
transcription.
|
||||
- **Default:** `"gemini-live"`
|
||||
- **Values:** `"gemini-live"`, `"whisper"`
|
||||
|
||||
- **`experimental.voice.whisperModel`** (enum):
|
||||
- **Description:** The Whisper model to use for local transcription.
|
||||
- **Default:** `"ggml-base.en.bin"`
|
||||
- **Values:** `"ggml-tiny.en.bin"`, `"ggml-base.en.bin"`,
|
||||
`"ggml-large-v3-turbo-q5_0.bin"`, `"ggml-large-v3-turbo-q8_0.bin"`
|
||||
|
||||
- **`experimental.voice.stopGracePeriodMs`** (number):
|
||||
- **Description:** How long to wait for final transcription after stopping
|
||||
recording.
|
||||
- **Default:** `1000`
|
||||
|
||||
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
|
||||
- **Description:** Enable non-interactive agent sessions.
|
||||
- **Default:** `false`
|
||||
@@ -1820,9 +1926,17 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.stressTestProfile`** (boolean):
|
||||
- **Description:** Significantly lowers token limits to force early garbage
|
||||
collection and distillation for testing purposes.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.autoMemory`** (boolean):
|
||||
- **Description:** Automatically extract reusable skills from past sessions in
|
||||
the background. Review results with /memory inbox.
|
||||
- **Description:** Automatically extract memory patches and skills from past
|
||||
sessions in the background. Every change is written as a unified diff
|
||||
`.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review
|
||||
in /memory inbox; nothing is applied until you approve it.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -2476,7 +2590,6 @@ for that specific session.
|
||||
- **Note:** For structured output and scripting, use the
|
||||
`--output-format json` or `--output-format stream-json` flag.
|
||||
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
|
||||
- **Deprecated:** Use positional arguments instead.
|
||||
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
|
||||
non-interactive mode.
|
||||
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
|
||||
|
||||
@@ -39,7 +39,7 @@ available combinations.
|
||||
| `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`<br />`Alt+Delete`<br />`Alt+D` |
|
||||
| `edit.deleteLeft` | Delete the character to the left. | `Backspace`<br />`Ctrl+H` |
|
||||
| `edit.deleteRight` | Delete the character to the right. | `Delete`<br />`Ctrl+D` |
|
||||
| `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`<br />`Alt+Z` |
|
||||
| `edit.undo` | Undo the most recent text edit. | `Ctrl+Z`<br />`Alt+Z`<br />`Cmd/Win+Z` |
|
||||
| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`<br />`Shift+Cmd/Win+Z`<br />`Alt+Shift+Z` |
|
||||
|
||||
#### Scrolling
|
||||
@@ -115,6 +115,7 @@ available combinations.
|
||||
| `app.restart` | Restart the application. | `R`<br />`Shift+R` |
|
||||
| `app.suspend` | Suspend the CLI and move it to the background. | `Ctrl+Z` |
|
||||
| `app.showShellUnfocusWarning` | Show warning when trying to move focus away from shell input. | `Tab` |
|
||||
| `app.voiceModePTT` | Hold to speak in Voice Mode. | `Space` |
|
||||
|
||||
#### Background Shell Controls
|
||||
|
||||
|
||||
@@ -71,7 +71,9 @@ primary conditions are the tool's name and its arguments.
|
||||
|
||||
#### Tool Name
|
||||
|
||||
The `toolName` in the rule must match the name of the tool being called.
|
||||
The `toolName` in the rule must match the name of the tool being called. For a
|
||||
complete list of built-in tool names, see the
|
||||
[Tools reference](/docs/reference/tools#available-tools).
|
||||
|
||||
- **Wildcards**: You can use wildcards to match multiple tools.
|
||||
- `*`: Matches **any tool** (built-in or MCP).
|
||||
@@ -87,7 +89,9 @@ The `toolName` in the rule must match the name of the tool being called.
|
||||
|
||||
If `argsPattern` is specified, the tool's arguments are converted to a stable
|
||||
JSON string, which is then tested against the provided regular expression. If
|
||||
the arguments don't match the pattern, the rule does not apply.
|
||||
the arguments don't match the pattern, the rule does not apply. For a list of
|
||||
argument keys available for each tool, see the **Parameters** in the
|
||||
[Tools reference](/docs/reference/tools#available-tools).
|
||||
|
||||
#### Execution environment
|
||||
|
||||
@@ -279,7 +283,11 @@ directory are **ignored**.
|
||||
|
||||
### TOML rule schema
|
||||
|
||||
Here is a breakdown of the fields available in a TOML policy rule:
|
||||
This section describes the fields available in a TOML policy rule.
|
||||
|
||||
For valid built-in `toolName` values and their argument structures (used by
|
||||
`argsPattern`), see the
|
||||
[Tools reference](/docs/reference/tools#available-tools).
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
@@ -365,6 +373,9 @@ priority = 10
|
||||
|
||||
To simplify writing policies for `run_shell_command`, you can use
|
||||
`commandPrefix` or `commandRegex` instead of the more complex `argsPattern`.
|
||||
These are policy-rule shorthands, not arguments of the `run_shell_command` tool
|
||||
itself. For the tool's invocation arguments, see [Shell tool](/docs/tools/shell)
|
||||
and [Tools reference](/docs/reference/tools#available-tools).
|
||||
|
||||
- `commandPrefix`: Matches if the `command` argument starts with the given
|
||||
string.
|
||||
|
||||
@@ -154,6 +154,55 @@ each tool.
|
||||
| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. |
|
||||
| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (for example, localhost), which may pose a security risk if used with untrusted prompts. In Plan Mode, this tool requires explicit user confirmation. |
|
||||
|
||||
### Tool argument keys
|
||||
|
||||
When writing [`argsPattern`](./policy-engine.md#arguments-pattern) rules for the
|
||||
[policy engine](./policy-engine.md), you need to know the JSON argument keys for
|
||||
each tool. The following table lists the keys that appear in the JSON
|
||||
representation of each tool's arguments.
|
||||
|
||||
| Tool | JSON argument keys |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `run_shell_command` | `command`, `description`, `dir_path`, `is_background` |
|
||||
| `glob` | `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` |
|
||||
| `grep_search` | `pattern`, `dir_path`, `include_pattern`, `exclude_pattern`, `names_only`, `case_sensitive`, `fixed_strings`, `context`, `after`, `before`, `no_ignore`, `max_matches_per_file`, `total_max_matches` |
|
||||
| `list_directory` | `dir_path`, `ignore`, `file_filtering_options` |
|
||||
| `read_file` | `file_path`, `start_line`, `end_line` |
|
||||
| `read_many_files` | `include`, `exclude`, `recursive`, `useDefaultExcludes` |
|
||||
| `write_file` | `file_path`, `content` |
|
||||
| `replace` | `file_path`, `old_string`, `new_string`, `instruction`, `allow_multiple` |
|
||||
| `ask_user` | `questions` (array of `question`, `header`, `type`, `options`) |
|
||||
| `write_todos` | `todos` (array of `description`, `status`) |
|
||||
| `save_memory` | `fact` |
|
||||
| `activate_skill` | `name` |
|
||||
| `get_internal_docs` | `path` |
|
||||
| `enter_plan_mode` | `reason` |
|
||||
| `exit_plan_mode` | `plan_path` |
|
||||
| `tracker_create_task` | `title`, `description`, `type` |
|
||||
| `tracker_update_task` | `id`, `title`, `description`, `status`, `dependencies` |
|
||||
| `tracker_get_task` | `id` |
|
||||
| `tracker_list_tasks` | `status`, `type`, `parentId` |
|
||||
| `tracker_add_dependency` | `taskId`, `dependencyId` |
|
||||
| `tracker_visualize` | _(none)_ |
|
||||
| `update_topic` | `title`, `summary`, `strategic_intent` |
|
||||
| `google_web_search` | `query` |
|
||||
| `web_fetch` | `prompt` |
|
||||
|
||||
For example, to write a policy rule that blocks any `write_file` call targeting
|
||||
a `.env` file, you would match against the `file_path` key:
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "write_file"
|
||||
argsPattern = '"file_path":".*\.env"'
|
||||
decision = "deny"
|
||||
priority = 100
|
||||
denyMessage = "Writing to .env files is not allowed."
|
||||
```
|
||||
|
||||
For full argument descriptions and types, see the individual tool pages linked
|
||||
in the [tables above](#available-tools).
|
||||
|
||||
## Under the hood
|
||||
|
||||
For developers, the tool system is designed to be extensible and robust. The
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
Our release flows support both `dev` and `prod` environments.
|
||||
|
||||
The `dev` environment pushes to a private Github-hosted NPM repository, with the
|
||||
The `dev` environment pushes to a private GitHub-hosted NPM repository, with the
|
||||
package names beginning with `@google-gemini/**` instead of `@google/**`.
|
||||
|
||||
The `prod` environment pushes to the public global NPM registry via Wombat
|
||||
@@ -20,7 +20,7 @@ More information can be found about these systems in the
|
||||
|
||||
### Package scopes
|
||||
|
||||
| Package | `prod` (Wombat Dressing Room) | `dev` (Github Private NPM Repo) |
|
||||
| Package | `prod` (Wombat Dressing Room) | `dev` (GitHub Private NPM Repo) |
|
||||
| ---------- | ----------------------------- | ----------------------------------------- |
|
||||
| CLI | @google/gemini-cli | @google-gemini/gemini-cli |
|
||||
| Core | @google/gemini-cli-core | @google-gemini/gemini-cli-core A2A Server |
|
||||
|
||||
+24
-2
@@ -27,7 +27,7 @@
|
||||
"slug": "docs/cli/tutorials/file-management"
|
||||
},
|
||||
{
|
||||
"label": "Get started with Agent skills",
|
||||
"label": "Get started with Agent Skills",
|
||||
"slug": "docs/cli/tutorials/skills-getting-started"
|
||||
},
|
||||
{
|
||||
@@ -95,7 +95,29 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Agent Skills",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/cli/skills" },
|
||||
{
|
||||
"label": "Get started with Agent Skills",
|
||||
"slug": "docs/cli/tutorials/skills-getting-started"
|
||||
},
|
||||
{
|
||||
"label": "Creating Agent Skills",
|
||||
"slug": "docs/cli/creating-skills"
|
||||
},
|
||||
{
|
||||
"label": "Using Agent Skills",
|
||||
"slug": "docs/cli/using-agent-skills"
|
||||
},
|
||||
{
|
||||
"label": "Developer guide: Best practices",
|
||||
"slug": "docs/cli/skills-best-practices"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Auto Memory",
|
||||
"badge": "🔬",
|
||||
|
||||
@@ -40,4 +40,4 @@ The agent uses this tool to provide professional-grade assistance:
|
||||
## Next steps
|
||||
|
||||
- Learn how to [Use Agent Skills](../cli/skills.md).
|
||||
- See the [Creating Agent Skills](../cli/creating-skills.md) guide.
|
||||
- See the [Build agent skills](../cli/creating-skills.md) guide.
|
||||
|
||||
@@ -19,6 +19,23 @@ platforms, they execute with `bash -c`.
|
||||
- `is_background` (boolean, optional): Whether to move the process to the
|
||||
background immediately after starting.
|
||||
|
||||
### Policy engine shorthands
|
||||
|
||||
The [policy engine](../reference/policy-engine.md) provides two convenience
|
||||
fields for writing rules that target shell commands:
|
||||
|
||||
- `commandPrefix`: Matches if the `command` argument starts with a given string.
|
||||
- `commandRegex`: Matches if the `command` argument matches a given regular
|
||||
expression.
|
||||
|
||||
These are syntactic sugar for combining `toolName = "run_shell_command"` with an
|
||||
`argsPattern` in a policy TOML file. They are **not** arguments of
|
||||
`run_shell_command` itself.
|
||||
|
||||
For details on writing shell-specific policy rules, see
|
||||
[Special syntax for `run_shell_command`](../reference/policy-engine.md#special-syntax-for-run_shell_command)
|
||||
in the policy engine reference.
|
||||
|
||||
### Return values
|
||||
|
||||
The tool returns a JSON object containing:
|
||||
|
||||
@@ -54,6 +54,7 @@ export default tseslint.config(
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'eslint.config.js',
|
||||
'**/coverage/**',
|
||||
'packages/**/dist/**',
|
||||
'bundle/**',
|
||||
'package/bundle/**',
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Live-LLM evals that pin down the auto-memory inbox contract:
|
||||
* 1. Canonical filename — agent uses `.inbox/<kind>/extraction.patch`.
|
||||
* 2. Incremental merge — agent rewrites an existing extraction.patch
|
||||
* instead of creating new patch files alongside.
|
||||
* 3. Absolute-path pointers — when the agent creates a sibling .md, the
|
||||
* paired MEMORY.md hunk references it by absolute path.
|
||||
* 4. Project-root protection — agent never writes to
|
||||
* `<projectRoot>/GEMINI.md` even when content is team-shared.
|
||||
*
|
||||
* Each test seeds session transcripts with strong, consistent signal so the
|
||||
* extraction agent will reasonably produce SOME output (or, in the human-only
|
||||
* test, refrain from producing output that targets forbidden paths). Tests
|
||||
* are USUALLY_PASSES policy because LLM behavior is stochastic; the harness
|
||||
* already retries up to 3 times.
|
||||
*/
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect } from 'vitest';
|
||||
import {
|
||||
type Config,
|
||||
ApprovalMode,
|
||||
SESSION_FILE_PREFIX,
|
||||
getProjectHash,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
|
||||
interface SeedSession {
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
userTurns: string[];
|
||||
/** Minutes ago the session ended (must be ≥ 180 to clear the idle gate). */
|
||||
timestampOffsetMinutes: number;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
content: Array<{ text: string }>;
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES = {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
name: 'auto-memory-contract-eval',
|
||||
private: true,
|
||||
scripts: { build: 'echo build', test: 'echo test' },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'README.md': '# Auto Memory Contract Eval\n\nFixture workspace.\n',
|
||||
};
|
||||
|
||||
const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
experimentalAutoMemory: true,
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
function buildMessages(userTurns: string[]): MessageRecord[] {
|
||||
const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
|
||||
return userTurns.flatMap((text, index) => [
|
||||
{
|
||||
id: `u${index + 1}`,
|
||||
timestamp: baseTime,
|
||||
type: 'user',
|
||||
content: [{ text }],
|
||||
},
|
||||
{
|
||||
id: `a${index + 1}`,
|
||||
timestamp: baseTime,
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Acknowledged.' }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function seedSessions(
|
||||
config: Config,
|
||||
sessions: SeedSession[],
|
||||
): Promise<void> {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
await fsp.mkdir(chatsDir, { recursive: true });
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionTimestamp = new Date(
|
||||
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
|
||||
);
|
||||
const timestamp = sessionTimestamp
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
|
||||
const conversation = {
|
||||
sessionId: session.sessionId,
|
||||
projectHash: getProjectHash(projectRoot),
|
||||
summary: session.summary,
|
||||
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: sessionTimestamp.toISOString(),
|
||||
messages: buildMessages(session.userTurns),
|
||||
};
|
||||
await fsp.writeFile(
|
||||
path.join(chatsDir, filename),
|
||||
JSON.stringify(conversation, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InboxSnapshot {
|
||||
privateFiles: string[];
|
||||
globalFiles: string[];
|
||||
privateContents: Map<string, string>;
|
||||
}
|
||||
|
||||
async function snapshotInbox(config: Config): Promise<InboxSnapshot> {
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inbox: InboxSnapshot = {
|
||||
privateFiles: [],
|
||||
globalFiles: [],
|
||||
privateContents: new Map(),
|
||||
};
|
||||
for (const kind of ['private', 'global'] as const) {
|
||||
const dir = path.join(memoryDir, '.inbox', kind);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fsp.readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const patchFiles = entries.filter((f) => f.endsWith('.patch')).sort();
|
||||
if (kind === 'private') {
|
||||
inbox.privateFiles = patchFiles;
|
||||
for (const fileName of patchFiles) {
|
||||
try {
|
||||
inbox.privateContents.set(
|
||||
fileName,
|
||||
await fsp.readFile(path.join(dir, fileName), 'utf-8'),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inbox.globalFiles = patchFiles;
|
||||
}
|
||||
}
|
||||
return inbox;
|
||||
}
|
||||
|
||||
describe('Auto Memory Contract', () => {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'uses canonical extraction.patch filename when writing private memory',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'verify-memory-cmd-1',
|
||||
summary:
|
||||
'Confirm that this project verifies memory edits with `npm run verify:memory`',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'For this project, every memory-system change is verified with `npm run verify:memory` before we hand the change back.',
|
||||
'That command is the gate. Without it the change is not considered done.',
|
||||
'It runs typechecks, the related unit tests, and a snapshot diff.',
|
||||
'Future agents working on memory should always run it after editing memoryService or commands/memory.ts.',
|
||||
'This is a durable rule for this project, not a one-off.',
|
||||
'The check is fast, under a minute, and failure means revert.',
|
||||
'Treat it as part of the memory subsystem contract.',
|
||||
'I want this remembered for next time.',
|
||||
'It applies to anything in packages/core/src/services/memoryService.ts and packages/core/src/commands/memory.ts.',
|
||||
'Make sure agents do not skip the verify step.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'verify-memory-cmd-2',
|
||||
summary: 'Same memory-verify command in another session',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'I had to remind the previous agent to run `npm run verify:memory` again.',
|
||||
'It is the durable verification command for memory edits in this repo.',
|
||||
'The agent forgot, even though we agreed last time.',
|
||||
'Please remember it for future memory-related work.',
|
||||
'It is the official verification step for memory changes.',
|
||||
'Run it whenever you touch memoryService.ts or commands/memory.ts.',
|
||||
'No exceptions. The command must finish green.',
|
||||
'This is a recurring rule across multiple sessions now.',
|
||||
'Make this part of your standard workflow for memory work.',
|
||||
'Verified again that the command catches regressions in MEMORY.md handling.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
|
||||
// Either the agent extracted nothing (acceptable no-op) OR it extracted
|
||||
// exactly one canonical file per kind. Multiple files per kind violates
|
||||
// the contract.
|
||||
expect(inbox.privateFiles.length).toBeLessThanOrEqual(1);
|
||||
expect(inbox.globalFiles.length).toBeLessThanOrEqual(1);
|
||||
|
||||
// Strong assertion: when the agent DID write a private patch, it must
|
||||
// be the canonical filename.
|
||||
if (inbox.privateFiles.length === 1) {
|
||||
expect(inbox.privateFiles[0]).toBe('extraction.patch');
|
||||
}
|
||||
if (inbox.globalFiles.length === 1) {
|
||||
expect(inbox.globalFiles[0]).toBe('extraction.patch');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'merges new findings into existing extraction.patch instead of creating new files',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inboxPrivate = path.join(memoryDir, '.inbox', 'private');
|
||||
await fsp.mkdir(inboxPrivate, { recursive: true });
|
||||
|
||||
// Pre-existing canonical patch left over from a prior session.
|
||||
const existingMemoryMd = path.join(memoryDir, 'MEMORY.md');
|
||||
const preExistingPatch = [
|
||||
`--- /dev/null`,
|
||||
`+++ ${existingMemoryMd}`,
|
||||
`@@ -0,0 +1,3 @@`,
|
||||
`+# Project Memory`,
|
||||
`+`,
|
||||
`+- This project lints with \`npm run lint\` (recurring rule from session 1).`,
|
||||
``,
|
||||
].join('\n');
|
||||
await fsp.writeFile(
|
||||
path.join(inboxPrivate, 'extraction.patch'),
|
||||
preExistingPatch,
|
||||
);
|
||||
|
||||
// New session that surfaces a different durable fact.
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'incremental-typecheck-cmd',
|
||||
summary:
|
||||
'Confirm that typecheck for memory edits uses `npm run typecheck`',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Always run `npm run typecheck` after editing any *.ts file in this repo.',
|
||||
'It is the standard typecheck command for the whole monorepo.',
|
||||
'Future agents should follow this without being reminded.',
|
||||
'It catches type errors before tests, much faster.',
|
||||
'Run it on every TypeScript edit, no exceptions.',
|
||||
'This is durable across the whole project.',
|
||||
'It is the project-wide convention for TS work.',
|
||||
'Make sure to run it after edits to memoryService.ts especially.',
|
||||
'It is fast and catches regressions early.',
|
||||
'Treat it as standard workflow.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
|
||||
// Contract: still ONLY ONE file in private inbox, and its name is the
|
||||
// canonical extraction.patch.
|
||||
expect(inbox.privateFiles).toEqual(['extraction.patch']);
|
||||
|
||||
// The single canonical patch must STILL contain the old hunk (the
|
||||
// agent must merge with existing rather than replace blindly), AND
|
||||
// ideally also contain the new typecheck fact.
|
||||
const merged = inbox.privateContents.get('extraction.patch') ?? '';
|
||||
expect(merged).toMatch(/npm run lint/);
|
||||
// Soft assertion: the agent SHOULD have added the new fact too. We
|
||||
// don't fail the test if it didn't (the agent may legitimately decide
|
||||
// the new fact isn't durable enough), but the file must be intact.
|
||||
// The hard assertion (no proliferation + old content preserved) is
|
||||
// what we lock down.
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'uses absolute paths in MEMORY.md sibling pointer lines',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
// Sessions whose extracted memory has substantial detail — encourages
|
||||
// the agent to spawn a sibling .md file (per prompt guidance).
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'detailed-release-workflow-1',
|
||||
summary: 'Detailed release workflow that runs across multiple steps',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Our release workflow has several distinct phases that future agents need to follow exactly.',
|
||||
'Phase 1 (preflight): run `npm run lint`, `npm run typecheck`, and `npm test` in that order.',
|
||||
'Phase 2 (build): run `npm run build` and verify dist/ outputs against a checksum file.',
|
||||
'Phase 3 (publish): run `npm run publish:dry-run` first, then `npm run publish` if no errors.',
|
||||
'Phase 4 (post): tag the commit with `git tag v$(jq -r .version package.json)` and push.',
|
||||
'There are pitfalls: phase 2 will silently succeed if dist/ is stale, so always check the checksum.',
|
||||
'Phase 3 must NEVER be skipped for hotfixes; the dry-run catches credential issues.',
|
||||
'The checklist is durable across all releases for this repo.',
|
||||
'Future agents should reproduce these phases in order without omitting any.',
|
||||
'This is the canonical release procedure for this project.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'detailed-release-workflow-2',
|
||||
summary: 'Reusing the same multi-phase release workflow',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'I just ran the release workflow again and it caught an issue in phase 2 because the checksum mismatched.',
|
||||
'Confirms the durable rule: always check the dist/ checksum after building.',
|
||||
'The 4-phase release procedure (preflight, build, publish, post) is the recurring workflow.',
|
||||
'I want this captured as durable memory because we use it every release.',
|
||||
'Each phase has multiple sub-steps and pitfalls, so it deserves substantial detail.',
|
||||
'Please remember the phases for future agents.',
|
||||
'The procedure has been the same for the last 6 releases.',
|
||||
'It includes the verify-checksum step that just saved us from a bad publish.',
|
||||
'This is a recurring multi-step workflow, not a one-off.',
|
||||
'Make sure future sessions know about all 4 phases and their pitfalls.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
|
||||
// The agent might choose to add brief facts directly to MEMORY.md
|
||||
// without spawning a sibling. That's a valid outcome; we only enforce
|
||||
// the absolute-path rule WHEN a sibling is created.
|
||||
if (inbox.privateFiles.length === 0) {
|
||||
return; // No-op extraction: nothing to assert.
|
||||
}
|
||||
expect(inbox.privateFiles).toEqual(['extraction.patch']);
|
||||
|
||||
const patch = inbox.privateContents.get('extraction.patch') ?? '';
|
||||
|
||||
// Find any /dev/null sibling-creation hunk that targets <memoryDir>/<x>.md
|
||||
// (where x != MEMORY).
|
||||
const siblingPattern = new RegExp(
|
||||
`\\+\\+\\+ ${memoryDir.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}/([^\\s/]+)\\.md`,
|
||||
'g',
|
||||
);
|
||||
const siblingTargets: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = siblingPattern.exec(patch)) !== null) {
|
||||
const name = match[1];
|
||||
// Skip MEMORY.md updates (those aren't siblings).
|
||||
if (name.toLowerCase() !== 'memory') {
|
||||
siblingTargets.push(`${name}.md`);
|
||||
}
|
||||
}
|
||||
|
||||
if (siblingTargets.length === 0) {
|
||||
return; // No sibling creations; nothing more to check.
|
||||
}
|
||||
|
||||
// For each created sibling, the patch must contain a MEMORY.md
|
||||
// pointer line that uses the ABSOLUTE path. Bare basename references
|
||||
// are the bug we're guarding against.
|
||||
for (const sibling of siblingTargets) {
|
||||
const absolutePath = path.join(memoryDir, sibling);
|
||||
// Look for an added line referencing the sibling.
|
||||
const addedLines = patch
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('+'));
|
||||
const referencingLines = addedLines.filter((line) =>
|
||||
line.includes(sibling),
|
||||
);
|
||||
expect(
|
||||
referencingLines.length,
|
||||
`Expected a MEMORY.md pointer for ${sibling} (auto-bundle would also add one).`,
|
||||
).toBeGreaterThan(0);
|
||||
const allAbsolute = referencingLines.every((line) =>
|
||||
line.includes(absolutePath),
|
||||
);
|
||||
expect(
|
||||
allAbsolute,
|
||||
`Pointer for ${sibling} must use absolute path. Saw: ${referencingLines.join(' | ')}`,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'auto-memory-contract',
|
||||
suiteType: 'component-level',
|
||||
name: 'never writes to <projectRoot>/GEMINI.md even for team-shared facts',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 240000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
setup: async (config) => {
|
||||
// Sessions that talk about TEAM CONVENTIONS — the kind of content that
|
||||
// would be a perfect fit for <projectRoot>/GEMINI.md, but the prompt
|
||||
// forbids the extraction agent from touching it.
|
||||
await seedSessions(config, [
|
||||
{
|
||||
sessionId: 'team-convention-pnpm-1',
|
||||
summary: 'Team convention: always use pnpm not npm for installs',
|
||||
timestampOffsetMinutes: 420,
|
||||
userTurns: [
|
||||
'Important team-wide convention for this repo: always use pnpm for installs, never npm.',
|
||||
'This is a shared rule across all engineers on the project.',
|
||||
'It applies to every package install, every clean, every dependency add.',
|
||||
'The rationale is workspace hoisting; npm would break the monorepo layout.',
|
||||
'This is a durable team rule, committed to the repo conventions.',
|
||||
'Future agents working in this repo should ALWAYS use pnpm.',
|
||||
'It is the standard team practice, no exceptions.',
|
||||
'Document it as part of the project conventions.',
|
||||
'Treat it as a hard rule for the team.',
|
||||
'I want this captured for future sessions.',
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionId: 'team-convention-pnpm-2',
|
||||
summary: 'Reaffirming the pnpm-only team rule in another session',
|
||||
timestampOffsetMinutes: 360,
|
||||
userTurns: [
|
||||
'Reminder again: this team uses pnpm exclusively, never npm.',
|
||||
'Another agent tried npm install and broke the lockfile.',
|
||||
'The team rule is clear: pnpm only for any install operation.',
|
||||
'It is part of our shared conventions for this codebase.',
|
||||
'Make sure future agents follow this team-wide rule.',
|
||||
'It applies to all engineers, all CI runs, all dev environments.',
|
||||
'The convention is durable and well-established for this repo.',
|
||||
'Agents should read this rule from project conventions before installing.',
|
||||
'No future agent should ever invoke `npm install` in this repo.',
|
||||
'Always pnpm. Always.',
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
assert: async (config) => {
|
||||
await startMemoryService(config);
|
||||
const inbox = await snapshotInbox(config);
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
// No private patch should target <projectRoot>/GEMINI.md or any
|
||||
// subdirectory GEMINI.md.
|
||||
const projectRootRegex = new RegExp(
|
||||
`\\+\\+\\+ ${projectRoot.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}.*GEMINI\\.md`,
|
||||
);
|
||||
for (const [name, content] of inbox.privateContents) {
|
||||
expect(
|
||||
projectRootRegex.test(content),
|
||||
`Private patch "${name}" must not target a GEMINI.md under <projectRoot>. Content:\n${content}`,
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// Verify on disk: <projectRoot>/GEMINI.md was not created or modified
|
||||
// by the extraction agent (snapshot rollback should also enforce this,
|
||||
// but we double-check from the post-run state).
|
||||
const projectGemini = path.join(projectRoot, 'GEMINI.md');
|
||||
const exists = await fsp
|
||||
.access(projectGemini)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
// The seeded workspace's WORKSPACE_FILES doesn't include GEMINI.md, so
|
||||
// it must NOT exist after the run.
|
||||
expect(
|
||||
exists,
|
||||
`<projectRoot>/GEMINI.md (${projectGemini}) must not be created by the extraction agent.`,
|
||||
).toBe(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { afterEach, beforeEach, describe, expect, vi } from 'vitest';
|
||||
import { runEval } from './test-helper.js';
|
||||
import { SESSION_FILE_PREFIX } from '../packages/core/src/services/chatRecordingService.js';
|
||||
|
||||
const evalState = vi.hoisted(() => ({
|
||||
sessionFilePath: '',
|
||||
debugLines: [] as string[],
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
localAgentCreate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor.js', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor.ts', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/agents/local-executor', () => ({
|
||||
LocalAgentExecutor: {
|
||||
create: mocks.localAgentCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService.js', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService.ts', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/services/executionLifecycleService', () => ({
|
||||
ExecutionLifecycleService: {
|
||||
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
|
||||
completeExecution: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger.ts', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../packages/core/src/utils/debugLogger', () => ({
|
||||
debugLogger: {
|
||||
debug: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
log: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
warn: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
error: (...args: unknown[]) =>
|
||||
evalState.debugLines.push(args.map(String).join(' ')),
|
||||
},
|
||||
}));
|
||||
|
||||
interface MockMemoryConfig {
|
||||
storage: {
|
||||
getProjectMemoryDir: () => string;
|
||||
getProjectMemoryTempDir: () => string;
|
||||
getProjectSkillsMemoryDir: () => string;
|
||||
getProjectTempDir: () => string;
|
||||
getProjectRoot: () => string;
|
||||
};
|
||||
getTargetDir: () => string;
|
||||
getToolRegistry: () => unknown;
|
||||
getGeminiClient: () => unknown;
|
||||
getSkillManager: () => { getSkills: () => unknown[] };
|
||||
isAutoMemoryEnabled: () => boolean;
|
||||
modelConfigService: {
|
||||
registerRuntimeModelConfig: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
sandboxManager: undefined;
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
rootDir: string;
|
||||
homeDir: string;
|
||||
targetDir: string;
|
||||
projectTempDir: string;
|
||||
memoryDir: string;
|
||||
skillsDir: string;
|
||||
config: MockMemoryConfig;
|
||||
}
|
||||
|
||||
interface AutoMemoryRunSnapshot {
|
||||
sessionIds?: string[];
|
||||
memoryCandidatesCreated?: string[];
|
||||
memoryFilesUpdated?: string[];
|
||||
skillsCreated?: string[];
|
||||
}
|
||||
|
||||
const fixtures: Fixture[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
evalState.debugLines = [];
|
||||
evalState.sessionFilePath = '';
|
||||
mocks.localAgentCreate.mockReset();
|
||||
mocks.localAgentCreate.mockImplementation(
|
||||
async (_agent, context, onActivity) => ({
|
||||
run: vi.fn().mockImplementation(async () => {
|
||||
if (evalState.sessionFilePath) {
|
||||
const callId = `read-inbox-routing`;
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'auto-memory-eval',
|
||||
type: 'TOOL_CALL_START',
|
||||
data: {
|
||||
name: 'read_file',
|
||||
callId,
|
||||
args: { file_path: evalState.sessionFilePath },
|
||||
},
|
||||
});
|
||||
onActivity({
|
||||
isSubagentActivityEvent: true,
|
||||
agentName: 'auto-memory-eval',
|
||||
type: 'TOOL_CALL_END',
|
||||
data: { id: callId, data: { isError: false } },
|
||||
});
|
||||
}
|
||||
|
||||
const config = context.config as MockMemoryConfig;
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
const inboxDir = path.join(memoryDir, '.inbox');
|
||||
|
||||
const homeDir = process.env['GEMINI_CLI_HOME'] ?? os.homedir();
|
||||
const globalGeminiDir = path.join(homeDir, '.gemini');
|
||||
|
||||
await fs.mkdir(path.join(inboxDir, 'private'), { recursive: true });
|
||||
await fs.mkdir(path.join(inboxDir, 'global'), { recursive: true });
|
||||
|
||||
const privateTarget = path.join(memoryDir, 'verify-memory.md');
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'private', 'verify-memory.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ ${privateTarget}`,
|
||||
`@@ -0,0 +1,3 @@`,
|
||||
`+# Project Memory Candidate`,
|
||||
`+`,
|
||||
`+Future agents should remember that this project verifies memory changes with \`npm run verify:memory\`.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const globalTarget = path.join(globalGeminiDir, 'GEMINI.md');
|
||||
await fs.writeFile(
|
||||
path.join(inboxDir, 'global', 'reply-style.patch'),
|
||||
[
|
||||
`--- /dev/null`,
|
||||
`+++ ${globalTarget}`,
|
||||
`@@ -0,0 +1,1 @@`,
|
||||
`+User prefers concise Chinese architecture plans.`,
|
||||
``,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
return {
|
||||
turn_count: 3,
|
||||
duration_ms: 25,
|
||||
terminate_reason: 'GOAL',
|
||||
};
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
while (fixtures.length > 0) {
|
||||
const fixture = fixtures.pop();
|
||||
if (fixture) {
|
||||
await fs.rm(fixture.rootDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function autoMemoryEval(name: string, fn: () => Promise<void>): void {
|
||||
runEval(
|
||||
'USUALLY_PASSES',
|
||||
{
|
||||
suiteName: 'auto-memory-modes',
|
||||
suiteType: 'component-level',
|
||||
name,
|
||||
timeout: 30000,
|
||||
},
|
||||
fn,
|
||||
40000,
|
||||
);
|
||||
}
|
||||
|
||||
async function createFixture(): Promise<Fixture> {
|
||||
const rootDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'gemini-auto-memory-eval-'),
|
||||
);
|
||||
const homeDir = path.join(rootDir, 'home');
|
||||
const targetDir = path.join(rootDir, 'workspace');
|
||||
const projectTempDir = path.join(rootDir, 'project-temp');
|
||||
const memoryDir = path.join(projectTempDir, 'memory');
|
||||
const skillsDir = path.join(memoryDir, 'skills');
|
||||
|
||||
await fs.mkdir(homeDir, { recursive: true });
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await fs.mkdir(path.join(projectTempDir, 'chats'), { recursive: true });
|
||||
vi.stubEnv('GEMINI_CLI_HOME', homeDir);
|
||||
|
||||
const config: MockMemoryConfig = {
|
||||
storage: {
|
||||
getProjectMemoryDir: () => memoryDir,
|
||||
getProjectMemoryTempDir: () => memoryDir,
|
||||
getProjectSkillsMemoryDir: () => skillsDir,
|
||||
getProjectTempDir: () => projectTempDir,
|
||||
getProjectRoot: () => targetDir,
|
||||
},
|
||||
getTargetDir: () => targetDir,
|
||||
getToolRegistry: () => ({}),
|
||||
getGeminiClient: () => ({}),
|
||||
getSkillManager: () => ({ getSkills: () => [] }),
|
||||
isAutoMemoryEnabled: () => true,
|
||||
modelConfigService: {
|
||||
registerRuntimeModelConfig: vi.fn(),
|
||||
},
|
||||
sandboxManager: undefined,
|
||||
};
|
||||
|
||||
const fixture = {
|
||||
rootDir,
|
||||
homeDir,
|
||||
targetDir,
|
||||
projectTempDir,
|
||||
memoryDir,
|
||||
skillsDir,
|
||||
config,
|
||||
};
|
||||
fixtures.push(fixture);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function seedSession(
|
||||
fixture: Fixture,
|
||||
sessionId: string,
|
||||
): Promise<string> {
|
||||
const sessionFilePath = path.join(
|
||||
fixture.projectTempDir,
|
||||
'chats',
|
||||
`${SESSION_FILE_PREFIX}2026-04-20T10-00-${sessionId}.json`,
|
||||
);
|
||||
const oldTimestamp = new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString();
|
||||
const messages = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `m${index + 1}`,
|
||||
timestamp: oldTimestamp,
|
||||
type: index % 2 === 0 ? 'user' : 'gemini',
|
||||
content: [
|
||||
{
|
||||
text:
|
||||
index % 2 === 0
|
||||
? 'For this project, durable memory changes are verified with `npm run verify:memory`.'
|
||||
: 'Acknowledged.',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFilePath,
|
||||
[
|
||||
{
|
||||
sessionId,
|
||||
projectHash: 'auto-memory-eval',
|
||||
summary: 'Capture durable auto memory routing behavior',
|
||||
startTime: oldTimestamp,
|
||||
lastUpdated: oldTimestamp,
|
||||
kind: 'main',
|
||||
},
|
||||
...messages,
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join('\n') + '\n',
|
||||
);
|
||||
|
||||
return sessionFilePath;
|
||||
}
|
||||
|
||||
async function expectSeedSessionEligible(
|
||||
fixture: Fixture,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const { buildSessionIndex } = await import(
|
||||
'../packages/core/src/services/memoryService.js'
|
||||
);
|
||||
const { newSessionIds } = await buildSessionIndex(
|
||||
path.join(fixture.projectTempDir, 'chats'),
|
||||
{ runs: [] },
|
||||
);
|
||||
expect(newSessionIds).toContain(sessionId);
|
||||
}
|
||||
|
||||
async function readRun(fixture: Fixture): Promise<AutoMemoryRunSnapshot> {
|
||||
const statePath = path.join(fixture.memoryDir, '.extraction-state.json');
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(statePath, 'utf-8');
|
||||
} catch (error) {
|
||||
let memoryEntries = '(memory dir missing)';
|
||||
try {
|
||||
memoryEntries = (await fs.readdir(fixture.memoryDir, { recursive: true }))
|
||||
.map(String)
|
||||
.join('\n');
|
||||
} catch {
|
||||
// Leave default diagnostic.
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`Expected extraction state at ${statePath}.`,
|
||||
`LocalAgentExecutor.create calls: ${mocks.localAgentCreate.mock.calls.length}`,
|
||||
`Memory dir entries:\n${memoryEntries}`,
|
||||
`Debug log:\n${evalState.debugLines.join('\n')}`,
|
||||
].join('\n'),
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
const state = JSON.parse(raw) as {
|
||||
runs?: AutoMemoryRunSnapshot[];
|
||||
};
|
||||
const run = state.runs?.at(-1);
|
||||
if (!run) {
|
||||
throw new Error('Expected an auto memory extraction run to be recorded');
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Auto Memory inbox routing', () => {
|
||||
autoMemoryEval(
|
||||
'every memory patch lands in .inbox/<kind>/ for review and active files stay untouched',
|
||||
async () => {
|
||||
const { startMemoryService } = await import(
|
||||
'../packages/core/src/services/memoryService.js'
|
||||
);
|
||||
const fixture = await createFixture();
|
||||
evalState.sessionFilePath = await seedSession(
|
||||
fixture,
|
||||
'inbox-routing-session',
|
||||
);
|
||||
await expectSeedSessionEligible(fixture, 'inbox-routing-session');
|
||||
|
||||
await startMemoryService(fixture.config as never);
|
||||
|
||||
const privatePatchPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'.inbox',
|
||||
'private',
|
||||
'verify-memory.patch',
|
||||
);
|
||||
const globalPatchPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'.inbox',
|
||||
'global',
|
||||
'reply-style.patch',
|
||||
);
|
||||
|
||||
const activePrivateMemoryPath = path.join(
|
||||
fixture.memoryDir,
|
||||
'verify-memory.md',
|
||||
);
|
||||
const activeGlobalMemoryPath = path.join(
|
||||
fixture.homeDir,
|
||||
'.gemini',
|
||||
'GEMINI.md',
|
||||
);
|
||||
const run = await readRun(fixture);
|
||||
|
||||
// Both patches were written to the inbox.
|
||||
await expect(fs.readFile(privatePatchPath, 'utf-8')).resolves.toContain(
|
||||
'npm run verify:memory',
|
||||
);
|
||||
await expect(fs.readFile(globalPatchPath, 'utf-8')).resolves.toContain(
|
||||
'concise Chinese architecture plans',
|
||||
);
|
||||
|
||||
// No active file was touched — every patch must be reviewed manually.
|
||||
expect(await fileExists(activePrivateMemoryPath)).toBe(false);
|
||||
expect(await fileExists(activeGlobalMemoryPath)).toBe(false);
|
||||
|
||||
// Run state records both patches as candidates and zero applied files.
|
||||
expect(run.memoryFilesUpdated ?? []).toEqual([]);
|
||||
expect(run.memoryCandidatesCreated ?? []).toEqual(
|
||||
expect.arrayContaining([
|
||||
path.relative(fixture.memoryDir, privatePatchPath),
|
||||
path.relative(fixture.memoryDir, globalPatchPath),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
describe('file_creation_behavior', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should create a new file in the correct directory when asked',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
}),
|
||||
'src/index.ts': 'console.log("hello");',
|
||||
},
|
||||
prompt:
|
||||
'Please create a new file called src/logger.ts containing a simple logging class. Do not modify any existing files.',
|
||||
assert: async (rig) => {
|
||||
// 1) Verify write_file tool was called
|
||||
const logs = rig.readToolLogs();
|
||||
const writeFileCalls = logs.filter(
|
||||
(log) => log.toolRequest?.name === 'write_file',
|
||||
);
|
||||
expect(
|
||||
writeFileCalls.length,
|
||||
'Expected a write_file call to create the new file',
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 2) Verify existing files were not modified
|
||||
const indexContent = rig.readFile('src/index.ts');
|
||||
expect(indexContent).toBe('console.log("hello");');
|
||||
|
||||
const pkgContent = rig.readFile('package.json');
|
||||
expect(JSON.parse(pkgContent).name).toBe('test-project');
|
||||
|
||||
// 3) Verify new file is created
|
||||
const loggerContent = rig.readFile('src/logger.ts');
|
||||
expect(loggerContent.length).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not overwrite existing file when creating new file with same name',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
}),
|
||||
'config.json': JSON.stringify({ port: 3000, env: 'production' }),
|
||||
},
|
||||
prompt:
|
||||
"Please create a new configuration file called config.json in the workspace. Ensure the port is set to 8080. Since there's already a config file there, make sure to check it first before making changes.",
|
||||
assert: async (rig) => {
|
||||
// Verify that read_file was called on config.json before write_file
|
||||
const logs = rig.readToolLogs();
|
||||
const targetReadFileIndex = logs.findIndex((log) => {
|
||||
if (log.toolRequest?.name !== 'read_file') return false;
|
||||
try {
|
||||
const args =
|
||||
typeof log.toolRequest.args === 'string'
|
||||
? JSON.parse(log.toolRequest.args)
|
||||
: log.toolRequest.args;
|
||||
return args.file_path === 'config.json';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const targetWriteFileIndex = logs.findIndex((log) => {
|
||||
if (log.toolRequest?.name !== 'write_file') return false;
|
||||
try {
|
||||
const args =
|
||||
typeof log.toolRequest.args === 'string'
|
||||
? JSON.parse(log.toolRequest.args)
|
||||
: log.toolRequest.args;
|
||||
return args.file_path === 'config.json';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
targetReadFileIndex,
|
||||
'Expected read_file to be called to inspect config.json before overwriting it',
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
|
||||
if (targetWriteFileIndex !== -1) {
|
||||
expect(
|
||||
targetReadFileIndex,
|
||||
'Expected read_file to be invoked before write_file for safety',
|
||||
).toBeLessThan(targetWriteFileIndex);
|
||||
}
|
||||
|
||||
// Also check the resulting config.json content
|
||||
const configContent = rig.readFile('config.json');
|
||||
expect(configContent).toContain('8080');
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should scaffold multiple related files in correct locations',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
type: 'module',
|
||||
}),
|
||||
},
|
||||
prompt:
|
||||
'Please scaffold auth validation and types by creating two new files: src/auth/validator.ts and src/auth/types.ts with relevant exports. Do not modify existing files.',
|
||||
assert: async (rig) => {
|
||||
// Verify files are created in right place
|
||||
const validatorContent = rig.readFile('src/auth/validator.ts');
|
||||
const typesContent = rig.readFile('src/auth/types.ts');
|
||||
|
||||
expect(validatorContent.length).toBeGreaterThan(0);
|
||||
expect(typesContent.length).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -78,4 +78,37 @@ describe('git repo eval', () => {
|
||||
expect(commitCalls.length).toBeGreaterThanOrEqual(1);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures that when the agent is prompted to commit its changes, it does not
|
||||
* use `git add .` or `git add -A`.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not stage changes via git add . when prompted to commit',
|
||||
prompt:
|
||||
'Make a targeted fix for the bug in index.ts without building, installing anything, or adding tests. Then, stage and commit your changes.',
|
||||
files: FILES,
|
||||
assert: async (rig, _result) => {
|
||||
const toolLogs = rig.readToolLogs();
|
||||
const gitAddAllCalls = toolLogs.filter((log) => {
|
||||
if (log.toolRequest.name !== 'run_shell_command') return false;
|
||||
try {
|
||||
const args = JSON.parse(log.toolRequest.args);
|
||||
if (!args.command) return false;
|
||||
const cmd = args.command.toLowerCase();
|
||||
return (
|
||||
cmd.includes('git add .') ||
|
||||
cmd.includes('git add -a') ||
|
||||
cmd.includes('git add --all')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(gitAddAllCalls.length).toBe(0);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,4 +420,54 @@ describe('plan_mode', () => {
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'plan_mode',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should invoke exit_plan_mode as a tool instead of a shell command',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings: {
|
||||
general: {
|
||||
plan: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
files: {
|
||||
'plans/my-plan.md': '# My Plan\n\n1. Step one',
|
||||
},
|
||||
prompt:
|
||||
'I agree with the plan in plans/my-plan.md. Please exit plan mode and then run `echo "Starting implementation"`',
|
||||
assert: async (rig) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Check if exit_plan_mode was called as a tool
|
||||
const exitPlanToolCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'exit_plan_mode',
|
||||
);
|
||||
|
||||
// Check if exit_plan_mode was called via shell
|
||||
const shellCalls = toolLogs.filter(
|
||||
(log) => log.toolRequest.name === 'run_shell_command',
|
||||
);
|
||||
const exitPlanViaShell = shellCalls.find((log) => {
|
||||
try {
|
||||
const args = JSON.parse(log.toolRequest.args);
|
||||
return args.command.includes('exit_plan_mode');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
exitPlanViaShell,
|
||||
'Should NOT call exit_plan_mode via run_shell_command',
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
exitPlanToolCall,
|
||||
'Should call exit_plan_mode tool directly',
|
||||
).toBeDefined();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,78 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
loadConversationRecord,
|
||||
SESSION_FILE_PREFIX,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
evalTest,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
function findDir(base: string, name: string): string | null {
|
||||
if (!fs.existsSync(base)) return null;
|
||||
const files = fs.readdirSync(base);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(base, file);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
if (file === name) return fullPath;
|
||||
const found = findDir(fullPath, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadLatestSessionRecord(homeDir: string, sessionId: string) {
|
||||
const chatsDir = findDir(path.join(homeDir, '.gemini'), 'chats');
|
||||
if (!chatsDir) {
|
||||
throw new Error('Could not find chats directory for eval session logs');
|
||||
}
|
||||
|
||||
const candidates = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.startsWith(SESSION_FILE_PREFIX) &&
|
||||
(file.endsWith('.json') || file.endsWith('.jsonl')),
|
||||
);
|
||||
|
||||
const matchingRecords = [];
|
||||
for (const file of candidates) {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
const record = await loadConversationRecord(filePath);
|
||||
if (record?.sessionId === sessionId) {
|
||||
matchingRecords.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
matchingRecords.sort(
|
||||
(a, b) => Date.parse(b.lastUpdated) - Date.parse(a.lastUpdated),
|
||||
);
|
||||
return matchingRecords[0] ?? null;
|
||||
}
|
||||
|
||||
async function waitForSessionScratchpad(
|
||||
homeDir: string,
|
||||
sessionId: string,
|
||||
timeoutMs = 30000,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const record = await loadLatestSessionRecord(homeDir, sessionId);
|
||||
if (record?.memoryScratchpad) {
|
||||
return record;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return loadLatestSessionRecord(homeDir, sessionId);
|
||||
}
|
||||
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
@@ -569,6 +635,103 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2SessionScratchpad =
|
||||
'Session summary persists memory scratchpad for memory-saving sessions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2SessionScratchpad,
|
||||
sessionId: 'memory-scratchpad-eval',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'Across all my projects, I prefer Vitest over Jest for testing.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Noted. What else should I keep in mind?' }],
|
||||
timestamp: '2026-01-01T00:00:05Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-3',
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'For this repo I was debugging a flaky API test earlier, but that was just transient context.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:01:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-4',
|
||||
type: 'gemini',
|
||||
content: [
|
||||
{ text: 'Understood. I will only save the durable preference.' },
|
||||
],
|
||||
timestamp: '2026-01-01T00:01:05Z',
|
||||
},
|
||||
],
|
||||
prompt:
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
expect(
|
||||
writeCalls.length,
|
||||
'Expected memoryV2 save flow to edit a markdown memory file',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await rig.run({
|
||||
args: ['--list-sessions'],
|
||||
approvalMode: 'yolo',
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
const record = await waitForSessionScratchpad(
|
||||
rig.homeDir!,
|
||||
'memory-scratchpad-eval',
|
||||
);
|
||||
expect(
|
||||
record?.memoryScratchpad,
|
||||
'Expected the resumed session log to contain a memoryScratchpad after session summary generation',
|
||||
).toBeDefined();
|
||||
expect(record?.memoryScratchpad?.version).toBe(1);
|
||||
expect(
|
||||
record?.memoryScratchpad?.toolSequence?.some((toolName) =>
|
||||
['write_file', 'replace'].includes(toolName),
|
||||
),
|
||||
'Expected memoryScratchpad.toolSequence to include the markdown editing tool used for memory persistence',
|
||||
).toBe(true);
|
||||
expect(
|
||||
record?.memoryScratchpad?.touchedPaths?.length,
|
||||
'Expected memoryScratchpad to capture at least one touched path',
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
record?.memoryScratchpad?.workflowSummary,
|
||||
'Expected memoryScratchpad.workflowSummary to be populated',
|
||||
).toMatch(/write_file|replace/i);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2RoutesUserProject =
|
||||
'Agent routes personal-to-user project notes to user-project memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
|
||||
+630
-17
@@ -6,21 +6,30 @@
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type Config,
|
||||
ApprovalMode,
|
||||
type MemoryScratchpad,
|
||||
SESSION_FILE_PREFIX,
|
||||
getProjectHash,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
import { ComponentRig, componentEvalTest } from './component-test-helper.js';
|
||||
import {
|
||||
average,
|
||||
averageNullable,
|
||||
countMatchingIds,
|
||||
roundStat,
|
||||
} from './statistics-helper.js';
|
||||
import { prepareWorkspace } from './test-helper.js';
|
||||
|
||||
interface SeedSession {
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
userTurns: string[];
|
||||
timestampOffsetMinutes: number;
|
||||
memoryScratchpad?: MemoryScratchpad;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
@@ -30,6 +39,81 @@ interface MessageRecord {
|
||||
content: Array<{ text: string }>;
|
||||
}
|
||||
|
||||
interface SessionVersion {
|
||||
sessionId: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface ExtractionRunSnapshot {
|
||||
sessionIds: string[];
|
||||
skillsCreated: string[];
|
||||
candidateSessions: SessionVersion[];
|
||||
processedSessions: SessionVersion[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}
|
||||
|
||||
interface ExtractionOutcome {
|
||||
state: { runs: ExtractionRunSnapshot[] };
|
||||
skillsDir: string;
|
||||
skillBodies: string[];
|
||||
}
|
||||
|
||||
interface SkillQualitySignal {
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
interface ScratchpadRunMetrics {
|
||||
turnCount: number | null;
|
||||
durationMs: number | null;
|
||||
terminateReason: string | null;
|
||||
skillsCreated: number;
|
||||
candidateSessions: number;
|
||||
processedSessions: number;
|
||||
relevantReads: number;
|
||||
distractorReads: number;
|
||||
totalReads: number;
|
||||
recall: number;
|
||||
precision: number;
|
||||
signalScore: number;
|
||||
skillQualityScore: number;
|
||||
skillQualityMax: number;
|
||||
skillQualityRatio: number;
|
||||
missingQualitySignals: string[];
|
||||
}
|
||||
|
||||
interface ScratchpadStatsTrial {
|
||||
trial: number;
|
||||
baseline: ScratchpadRunMetrics;
|
||||
enhanced: ScratchpadRunMetrics;
|
||||
}
|
||||
|
||||
interface ScratchpadStatsAggregate {
|
||||
turnCountAvg: number | null;
|
||||
durationMsAvg: number | null;
|
||||
recallAvg: number;
|
||||
precisionAvg: number;
|
||||
signalScoreAvg: number;
|
||||
relevantReadsAvg: number;
|
||||
distractorReadsAvg: number;
|
||||
skillsCreatedAvg: number;
|
||||
skillQualityScoreAvg: number;
|
||||
skillQualityRatioAvg: number;
|
||||
}
|
||||
|
||||
interface ScratchpadStatsReport {
|
||||
generatedAt: string;
|
||||
trials: number;
|
||||
aggregate: {
|
||||
baseline: ScratchpadStatsAggregate;
|
||||
enhanced: ScratchpadStatsAggregate;
|
||||
};
|
||||
deltas: ScratchpadStatsAggregate;
|
||||
results: ScratchpadStatsTrial[];
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES = {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
@@ -68,6 +152,143 @@ function buildMessages(userTurns: string[]): MessageRecord[] {
|
||||
]);
|
||||
}
|
||||
|
||||
function padTurns(turns: string[]): string[] {
|
||||
if (turns.length >= 10) {
|
||||
return turns;
|
||||
}
|
||||
|
||||
const padded = [...turns];
|
||||
for (let i = turns.length; i < 10; i++) {
|
||||
padded.push(`${turns[i % turns.length]} (repeat ${i + 1})`);
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
|
||||
function createScratchpad(
|
||||
workflowSummary: string,
|
||||
touchedPaths: string[],
|
||||
validationStatus: MemoryScratchpad['validationStatus'] = 'passed',
|
||||
): MemoryScratchpad {
|
||||
return {
|
||||
version: 1,
|
||||
workflowSummary,
|
||||
toolSequence: ['run_shell_command'],
|
||||
touchedPaths,
|
||||
validationStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkflowComparisonSessions(withScratchpad: boolean): {
|
||||
sessions: SeedSession[];
|
||||
relevantSessionIds: string[];
|
||||
distractorSessionIds: string[];
|
||||
} {
|
||||
const relevantWorkflowSummary =
|
||||
'run_shell_command -> run_shell_command | paths packages/cli/src/config/settings.ts, docs/settings.md | validated';
|
||||
|
||||
const relevantScratchpad = withScratchpad
|
||||
? createScratchpad(relevantWorkflowSummary, [
|
||||
'packages/cli/src/config/settings.ts',
|
||||
'docs/settings.md',
|
||||
])
|
||||
: undefined;
|
||||
|
||||
const sessions: SeedSession[] = [
|
||||
{
|
||||
sessionId: 'hidden-settings-workflow-a',
|
||||
summary: 'Prepare release notes for settings launch',
|
||||
timestampOffsetMinutes: 420,
|
||||
memoryScratchpad: relevantScratchpad,
|
||||
userTurns: padTurns([
|
||||
'When we add a new setting, the durable workflow is to regenerate the settings docs instead of editing them by hand.',
|
||||
'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
|
||||
'Skipping predocs leaves stale defaults in the generated docs.',
|
||||
'We verify the workflow by checking that both the schema output and docs update together.',
|
||||
'This exact command order is the recurring workflow we use for settings changes.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'hidden-settings-workflow-b',
|
||||
summary: 'Investigate CI drift in generated config reference',
|
||||
timestampOffsetMinutes: 390,
|
||||
memoryScratchpad: relevantScratchpad,
|
||||
userTurns: padTurns([
|
||||
'The config reference drift was fixed by rerunning the standard settings regeneration workflow.',
|
||||
'We again used npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
|
||||
'The recurring rule is never to hand-edit generated settings docs.',
|
||||
'The validation step is to confirm the schema artifact and docs changed together after regeneration.',
|
||||
'This is the same recurring workflow we use every time a setting changes.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-release-notes',
|
||||
summary: 'Prepare release notes for auth launch',
|
||||
timestampOffsetMinutes: 360,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This release-notes task was one-off and just needed manual wording updates.',
|
||||
'I edited CHANGELOG.md and docs/release-notes.md directly.',
|
||||
'There was no reusable command sequence here beyond proofreading the copy.',
|
||||
'This task should not become a standing workflow.',
|
||||
'Once the wording landed, we were done.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-ci-snapshots',
|
||||
summary: 'Investigate CI drift in auth snapshots',
|
||||
timestampOffsetMinutes: 330,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This auth snapshot issue was specific to a flaky test in CI.',
|
||||
'The only commands we ran were npm test -- auth and an isolated snapshot update.',
|
||||
'It was not the recurring settings-doc workflow.',
|
||||
'Once the flaky snapshot passed, there was no broader reusable procedure.',
|
||||
'Treat this as a one-off CI cleanup.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-onboarding-docs',
|
||||
summary: 'Refresh onboarding documentation copy',
|
||||
timestampOffsetMinutes: 300,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This was just a docs wording cleanup in docs/onboarding.md.',
|
||||
'No command sequence was involved.',
|
||||
'We manually edited the copy and reviewed it.',
|
||||
'There is no recurring operational workflow to capture here.',
|
||||
'This should stay a one-off docs edit.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-deploy-copy',
|
||||
summary: 'Adjust deployment checklist wording',
|
||||
timestampOffsetMinutes: 270,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This was a wording-only change to docs/deploy.md.',
|
||||
'We did not run a reusable command sequence.',
|
||||
'It should not become a skill.',
|
||||
'The edit was only for this deploy checklist cleanup.',
|
||||
'After the copy change, the task was complete.',
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
sessions,
|
||||
relevantSessionIds: [
|
||||
'hidden-settings-workflow-a',
|
||||
'hidden-settings-workflow-b',
|
||||
],
|
||||
distractorSessionIds: [
|
||||
'distractor-release-notes',
|
||||
'distractor-ci-snapshots',
|
||||
'distractor-onboarding-docs',
|
||||
'distractor-deploy-copy',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSessions(
|
||||
config: Config,
|
||||
sessions: SeedSession[],
|
||||
@@ -78,9 +299,10 @@ async function seedSessions(
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
for (const session of sessions) {
|
||||
const timestamp = new Date(
|
||||
const sessionTimestamp = new Date(
|
||||
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
|
||||
)
|
||||
);
|
||||
const timestamp = sessionTimestamp
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
@@ -89,8 +311,9 @@ async function seedSessions(
|
||||
sessionId: session.sessionId,
|
||||
projectHash: getProjectHash(projectRoot),
|
||||
summary: session.summary,
|
||||
memoryScratchpad: session.memoryScratchpad,
|
||||
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: sessionTimestamp.toISOString(),
|
||||
messages: buildMessages(session.userTurns),
|
||||
};
|
||||
|
||||
@@ -101,10 +324,9 @@ async function seedSessions(
|
||||
}
|
||||
}
|
||||
|
||||
async function runExtractionAndReadState(config: Config): Promise<{
|
||||
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
|
||||
skillsDir: string;
|
||||
}> {
|
||||
async function runExtractionAndReadState(
|
||||
config: Config,
|
||||
): Promise<ExtractionOutcome> {
|
||||
await startMemoryService(config);
|
||||
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
@@ -113,7 +335,15 @@ async function runExtractionAndReadState(config: Config): Promise<{
|
||||
|
||||
const raw = await fsp.readFile(statePath, 'utf-8');
|
||||
const state = JSON.parse(raw) as {
|
||||
runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
|
||||
runs?: Array<{
|
||||
sessionIds?: string[];
|
||||
skillsCreated?: string[];
|
||||
candidateSessions?: SessionVersion[];
|
||||
processedSessions?: SessionVersion[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}>;
|
||||
};
|
||||
if (!Array.isArray(state.runs) || state.runs.length === 0) {
|
||||
throw new Error('Skill extraction finished without writing any run state');
|
||||
@@ -126,27 +356,292 @@ async function runExtractionAndReadState(config: Config): Promise<{
|
||||
skillsCreated: Array.isArray(run.skillsCreated)
|
||||
? run.skillsCreated
|
||||
: [],
|
||||
candidateSessions: Array.isArray(run.candidateSessions)
|
||||
? run.candidateSessions
|
||||
: [],
|
||||
processedSessions: Array.isArray(run.processedSessions)
|
||||
? run.processedSessions
|
||||
: [],
|
||||
turnCount:
|
||||
typeof run.turnCount === 'number' ? run.turnCount : undefined,
|
||||
durationMs:
|
||||
typeof run.durationMs === 'number' ? run.durationMs : undefined,
|
||||
terminateReason:
|
||||
typeof run.terminateReason === 'string'
|
||||
? run.terminateReason
|
||||
: undefined,
|
||||
})),
|
||||
},
|
||||
skillsDir,
|
||||
skillBodies: await readSkillBodies(skillsDir),
|
||||
};
|
||||
}
|
||||
|
||||
async function summarizeScratchpadRun(
|
||||
outcome: ExtractionOutcome,
|
||||
run: ExtractionRunSnapshot,
|
||||
scenario: ReturnType<typeof createWorkflowComparisonSessions>,
|
||||
): Promise<ScratchpadRunMetrics> {
|
||||
const relevantReads = countMatchingIds(
|
||||
run.processedSessions,
|
||||
scenario.relevantSessionIds,
|
||||
);
|
||||
const distractorReads = countMatchingIds(
|
||||
run.processedSessions,
|
||||
scenario.distractorSessionIds,
|
||||
);
|
||||
const totalReads = run.processedSessions.length;
|
||||
const quality = scoreSkillQuality(
|
||||
outcome.skillBodies,
|
||||
SETTINGS_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
return {
|
||||
turnCount: run.turnCount ?? null,
|
||||
durationMs: run.durationMs ?? null,
|
||||
terminateReason: run.terminateReason ?? null,
|
||||
skillsCreated: run.skillsCreated.length,
|
||||
candidateSessions: run.candidateSessions.length,
|
||||
processedSessions: totalReads,
|
||||
relevantReads,
|
||||
distractorReads,
|
||||
totalReads,
|
||||
recall: relevantReads / scenario.relevantSessionIds.length,
|
||||
precision: totalReads === 0 ? 0 : relevantReads / totalReads,
|
||||
signalScore: relevantReads - distractorReads,
|
||||
skillQualityScore: quality.score,
|
||||
skillQualityMax: quality.maxScore,
|
||||
skillQualityRatio:
|
||||
quality.maxScore === 0 ? 0 : quality.score / quality.maxScore,
|
||||
missingQualitySignals: quality.missing,
|
||||
};
|
||||
}
|
||||
|
||||
function averageScratchpadRuns(
|
||||
runs: ScratchpadRunMetrics[],
|
||||
): ScratchpadStatsAggregate {
|
||||
return {
|
||||
turnCountAvg: roundStat(averageNullable(runs.map((run) => run.turnCount))),
|
||||
durationMsAvg: roundStat(
|
||||
averageNullable(runs.map((run) => run.durationMs)),
|
||||
),
|
||||
recallAvg: roundStat(average(runs.map((run) => run.recall))) ?? 0,
|
||||
precisionAvg: roundStat(average(runs.map((run) => run.precision))) ?? 0,
|
||||
signalScoreAvg: roundStat(average(runs.map((run) => run.signalScore))) ?? 0,
|
||||
relevantReadsAvg:
|
||||
roundStat(average(runs.map((run) => run.relevantReads))) ?? 0,
|
||||
distractorReadsAvg:
|
||||
roundStat(average(runs.map((run) => run.distractorReads))) ?? 0,
|
||||
skillsCreatedAvg:
|
||||
roundStat(average(runs.map((run) => run.skillsCreated))) ?? 0,
|
||||
skillQualityScoreAvg:
|
||||
roundStat(average(runs.map((run) => run.skillQualityScore))) ?? 0,
|
||||
skillQualityRatioAvg:
|
||||
roundStat(average(runs.map((run) => run.skillQualityRatio))) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function diffScratchpadAggregates(
|
||||
baseline: ScratchpadStatsAggregate,
|
||||
enhanced: ScratchpadStatsAggregate,
|
||||
): ScratchpadStatsAggregate {
|
||||
return {
|
||||
turnCountAvg:
|
||||
baseline.turnCountAvg === null || enhanced.turnCountAvg === null
|
||||
? null
|
||||
: roundStat(enhanced.turnCountAvg - baseline.turnCountAvg),
|
||||
durationMsAvg:
|
||||
baseline.durationMsAvg === null || enhanced.durationMsAvg === null
|
||||
? null
|
||||
: roundStat(enhanced.durationMsAvg - baseline.durationMsAvg),
|
||||
recallAvg: roundStat(enhanced.recallAvg - baseline.recallAvg) ?? 0,
|
||||
precisionAvg: roundStat(enhanced.precisionAvg - baseline.precisionAvg) ?? 0,
|
||||
signalScoreAvg:
|
||||
roundStat(enhanced.signalScoreAvg - baseline.signalScoreAvg) ?? 0,
|
||||
relevantReadsAvg:
|
||||
roundStat(enhanced.relevantReadsAvg - baseline.relevantReadsAvg) ?? 0,
|
||||
distractorReadsAvg:
|
||||
roundStat(enhanced.distractorReadsAvg - baseline.distractorReadsAvg) ?? 0,
|
||||
skillsCreatedAvg:
|
||||
roundStat(enhanced.skillsCreatedAvg - baseline.skillsCreatedAvg) ?? 0,
|
||||
skillQualityScoreAvg:
|
||||
roundStat(
|
||||
enhanced.skillQualityScoreAvg - baseline.skillQualityScoreAvg,
|
||||
) ?? 0,
|
||||
skillQualityRatioAvg:
|
||||
roundStat(
|
||||
enhanced.skillQualityRatioAvg - baseline.skillQualityRatioAvg,
|
||||
) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function runScenarioWithFreshRig(
|
||||
sessions: SeedSession[],
|
||||
): Promise<ExtractionOutcome> {
|
||||
const rig = new ComponentRig({
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
});
|
||||
try {
|
||||
await rig.initialize();
|
||||
await prepareWorkspace(rig.testDir, rig.testDir, WORKSPACE_FILES);
|
||||
await seedSessions(rig.config!, sessions);
|
||||
return await runExtractionAndReadState(rig.config!);
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runScratchpadStatsTrial(
|
||||
trial: number,
|
||||
): Promise<ScratchpadStatsTrial> {
|
||||
const baselineScenario = createWorkflowComparisonSessions(false);
|
||||
const enhancedScenario = createWorkflowComparisonSessions(true);
|
||||
|
||||
const baselineOutcome = await runScenarioWithFreshRig(
|
||||
baselineScenario.sessions,
|
||||
);
|
||||
const enhancedOutcome = await runScenarioWithFreshRig(
|
||||
enhancedScenario.sessions,
|
||||
);
|
||||
|
||||
const baselineRun = baselineOutcome.state.runs.at(-1);
|
||||
const enhancedRun = enhancedOutcome.state.runs.at(-1);
|
||||
if (!baselineRun || !enhancedRun) {
|
||||
throw new Error('Expected both baseline and scratchpad runs to exist');
|
||||
}
|
||||
|
||||
expectSuccessfulExtractionRun(baselineRun);
|
||||
expectSuccessfulExtractionRun(enhancedRun);
|
||||
|
||||
return {
|
||||
trial,
|
||||
baseline: await summarizeScratchpadRun(
|
||||
baselineOutcome,
|
||||
baselineRun,
|
||||
baselineScenario,
|
||||
),
|
||||
enhanced: await summarizeScratchpadRun(
|
||||
enhancedOutcome,
|
||||
enhancedRun,
|
||||
enhancedScenario,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function runScratchpadStatsReport(
|
||||
trials: number,
|
||||
): Promise<ScratchpadStatsReport> {
|
||||
const results: ScratchpadStatsTrial[] = [];
|
||||
|
||||
for (let trial = 1; trial <= trials; trial++) {
|
||||
results.push(await runScratchpadStatsTrial(trial));
|
||||
}
|
||||
|
||||
const baseline = averageScratchpadRuns(
|
||||
results.map((result) => result.baseline),
|
||||
);
|
||||
const enhanced = averageScratchpadRuns(
|
||||
results.map((result) => result.enhanced),
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
trials,
|
||||
aggregate: {
|
||||
baseline,
|
||||
enhanced,
|
||||
},
|
||||
deltas: diffScratchpadAggregates(baseline, enhanced),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeScratchpadStatsReport(
|
||||
report: ScratchpadStatsReport,
|
||||
): Promise<string> {
|
||||
const outputPath = path.resolve(
|
||||
process.cwd(),
|
||||
'evals/logs/skill_extraction_scratchpad_stats.json',
|
||||
);
|
||||
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fsp.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async function readSkillBodies(skillsDir: string): Promise<string[]> {
|
||||
const bodies: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
|
||||
const skillDirs = entries.filter((entry) => entry.isDirectory());
|
||||
const bodies = await Promise.all(
|
||||
skillDirs.map((entry) =>
|
||||
fsp.readFile(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf-8'),
|
||||
),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
bodies.push(
|
||||
await fsp.readFile(
|
||||
path.join(skillsDir, entry.name, 'SKILL.md'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Ignore incomplete skill directories so one bad artifact does not hide
|
||||
// valid skills created in the same eval run.
|
||||
}
|
||||
}
|
||||
return bodies;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function expectSuccessfulExtractionRun(run: ExtractionRunSnapshot): void {
|
||||
expect(run.turnCount).toBeGreaterThan(0);
|
||||
expect(run.turnCount).toBeLessThanOrEqual(30);
|
||||
expect(run.durationMs).toBeGreaterThan(0);
|
||||
expect(run.terminateReason).toBe('GOAL');
|
||||
}
|
||||
|
||||
function scoreSkillQuality(
|
||||
skillBodies: string[],
|
||||
signals: SkillQualitySignal[],
|
||||
): { score: number; maxScore: number; missing: string[] } {
|
||||
const combined = skillBodies.join('\n\n');
|
||||
const matched = signals.filter((signal) => signal.pattern.test(combined));
|
||||
|
||||
return {
|
||||
score: matched.length,
|
||||
maxScore: signals.length,
|
||||
missing: signals
|
||||
.filter((signal) => !signal.pattern.test(combined))
|
||||
.map((signal) => signal.label),
|
||||
};
|
||||
}
|
||||
|
||||
const SETTINGS_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
|
||||
{ label: 'predocs command', pattern: /npm run predocs:settings/i },
|
||||
{ label: 'schema command', pattern: /npm run schema:settings/i },
|
||||
{ label: 'docs command', pattern: /npm run docs:settings/i },
|
||||
{ label: 'verification guidance', pattern: /verif(?:y|ication)/i },
|
||||
{
|
||||
label: 'generated docs warning or ordering constraint',
|
||||
pattern:
|
||||
/do not hand-edit|manual edits|exact command order|preserve.*order/i,
|
||||
},
|
||||
];
|
||||
|
||||
const DB_MIGRATION_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
|
||||
{ label: 'db check command', pattern: /npm run db:check/i },
|
||||
{ label: 'db migrate command', pattern: /npm run db:migrate/i },
|
||||
{ label: 'db validate command', pattern: /npm run db:validate/i },
|
||||
{ label: 'rollback guidance', pattern: /npm run db:rollback|rollback/i },
|
||||
{
|
||||
label: 'ordering constraint',
|
||||
pattern: /check.*migrate.*validate|ordering is critical|mandatory/i,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared configOverrides for all skill extraction component evals.
|
||||
* - experimentalAutoMemory: enables the Auto Memory skill extraction pipeline.
|
||||
@@ -158,6 +653,16 @@ const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
function parseScratchpadStatsTrials(): number {
|
||||
const configured = Number.parseInt(
|
||||
process.env['SCRATCHPAD_STATS_TRIALS'] ?? '8',
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : 8;
|
||||
}
|
||||
|
||||
const SCRATCHPAD_STATS_TRIALS = parseScratchpadStatsTrials();
|
||||
|
||||
describe('Skill Extraction', () => {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
@@ -264,15 +769,24 @@ describe('Skill Extraction', () => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
const quality = scoreSkillQuality(
|
||||
skillBodies,
|
||||
SETTINGS_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expectSuccessfulExtractionRun(state.runs[0]);
|
||||
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(combinedSkills).toContain('npm run predocs:settings');
|
||||
expect(combinedSkills).toContain('npm run schema:settings');
|
||||
expect(combinedSkills).toContain('npm run docs:settings');
|
||||
expect(combinedSkills).toMatch(/Verification/i);
|
||||
expect(combinedSkills).toMatch(/verif(?:y|ication)/i);
|
||||
expect(
|
||||
quality.score,
|
||||
`missing quality signals: ${quality.missing.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
@@ -281,6 +795,96 @@ describe('Skill Extraction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'memory scratchpad improves repeated-workflow recall versus summary-only index',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 360000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
assert: async () => {
|
||||
const baselineScenario = createWorkflowComparisonSessions(false);
|
||||
const enhancedScenario = createWorkflowComparisonSessions(true);
|
||||
|
||||
const baselineOutcome = await runScenarioWithFreshRig(
|
||||
baselineScenario.sessions,
|
||||
);
|
||||
const enhancedOutcome = await runScenarioWithFreshRig(
|
||||
enhancedScenario.sessions,
|
||||
);
|
||||
|
||||
const baselineRun = baselineOutcome.state.runs.at(-1);
|
||||
const enhancedRun = enhancedOutcome.state.runs.at(-1);
|
||||
if (!baselineRun || !enhancedRun) {
|
||||
throw new Error('Expected both baseline and scratchpad runs to exist');
|
||||
}
|
||||
|
||||
expectSuccessfulExtractionRun(baselineRun);
|
||||
expectSuccessfulExtractionRun(enhancedRun);
|
||||
|
||||
const baselineRelevantReads = countMatchingIds(
|
||||
baselineRun.processedSessions,
|
||||
baselineScenario.relevantSessionIds,
|
||||
);
|
||||
const enhancedRelevantReads = countMatchingIds(
|
||||
enhancedRun.processedSessions,
|
||||
enhancedScenario.relevantSessionIds,
|
||||
);
|
||||
const baselineDistractorReads = countMatchingIds(
|
||||
baselineRun.processedSessions,
|
||||
baselineScenario.distractorSessionIds,
|
||||
);
|
||||
const enhancedDistractorReads = countMatchingIds(
|
||||
enhancedRun.processedSessions,
|
||||
enhancedScenario.distractorSessionIds,
|
||||
);
|
||||
const baselineSignalScore =
|
||||
baselineRelevantReads - baselineDistractorReads;
|
||||
const enhancedSignalScore =
|
||||
enhancedRelevantReads - enhancedDistractorReads;
|
||||
|
||||
expect(enhancedRun.candidateSessions).toHaveLength(
|
||||
enhancedScenario.sessions.length,
|
||||
);
|
||||
expect(enhancedRelevantReads).toBeGreaterThanOrEqual(2);
|
||||
expect(enhancedRelevantReads).toBeGreaterThanOrEqual(
|
||||
baselineRelevantReads,
|
||||
);
|
||||
expect(enhancedDistractorReads).toBeLessThanOrEqual(
|
||||
baselineDistractorReads,
|
||||
);
|
||||
expect(enhancedSignalScore).toBeGreaterThan(baselineSignalScore);
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env['RUN_SCRATCHPAD_STATS'] === '1') {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'reports memory scratchpad retrieval statistics',
|
||||
timeout: Math.max(360000, SCRATCHPAD_STATS_TRIALS * 150000),
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
assert: async () => {
|
||||
const report = await runScratchpadStatsReport(SCRATCHPAD_STATS_TRIALS);
|
||||
const outputPath = await writeScratchpadStatsReport(report);
|
||||
|
||||
console.info(
|
||||
`Wrote scratchpad stats report to ${outputPath}\n${JSON.stringify(
|
||||
report.aggregate,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(report.results).toHaveLength(SCRATCHPAD_STATS_TRIALS);
|
||||
expect(report.aggregate.baseline.recallAvg).toBeGreaterThan(0);
|
||||
expect(report.aggregate.enhanced.recallAvg).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
it.skip('reports memory scratchpad retrieval statistics', () => {});
|
||||
}
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
@@ -330,15 +934,24 @@ describe('Skill Extraction', () => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
const quality = scoreSkillQuality(
|
||||
skillBodies,
|
||||
DB_MIGRATION_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expectSuccessfulExtractionRun(state.runs[0]);
|
||||
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(combinedSkills).toContain('npm run db:check');
|
||||
expect(combinedSkills).toContain('npm run db:migrate');
|
||||
expect(combinedSkills).toContain('npm run db:validate');
|
||||
expect(combinedSkills).toMatch(/rollback/i);
|
||||
expect(
|
||||
quality.score,
|
||||
`missing quality signals: ${quality.missing.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export function countMatchingIds<T extends { sessionId: string }>(
|
||||
items: T[],
|
||||
expectedIds: string[],
|
||||
): number {
|
||||
const expected = new Set(expectedIds);
|
||||
return items.filter((item) => expected.has(item.sessionId)).length;
|
||||
}
|
||||
|
||||
export function roundStat(value: number | null): number | null {
|
||||
return value === null ? null : Number(value.toFixed(4));
|
||||
}
|
||||
|
||||
export function average(values: number[]): number {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
export function averageNullable(values: Array<number | null>): number | null {
|
||||
const numericValues = values.filter((value) => value !== null);
|
||||
return numericValues.length === 0 ? null : average(numericValues);
|
||||
}
|
||||
+50
-8
@@ -62,11 +62,13 @@ describe('tracker_mode', () => {
|
||||
'Expected tracker_update_task tool to be called',
|
||||
).toBe(true);
|
||||
|
||||
const updateCall = toolLogs.find(
|
||||
const updateCalls = toolLogs.filter(
|
||||
(log) => log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
const updateArgs = JSON.parse(updateCall!.toolRequest.args);
|
||||
expect(updateCalls.length).toBeGreaterThan(0);
|
||||
const updateArgs = JSON.parse(
|
||||
updateCalls[updateCalls.length - 1].toolRequest.args,
|
||||
);
|
||||
expect(updateArgs.status).toBe('closed');
|
||||
|
||||
const loginContent = fs.readFileSync(
|
||||
@@ -128,12 +130,52 @@ describe('tracker_mode', () => {
|
||||
prompt:
|
||||
'Where is my task tracker storage located? Please provide the absolute path in your response.',
|
||||
assert: async (rig, result) => {
|
||||
// The rig sets GEMINI_CLI_HOME to rig.homeDir
|
||||
const homeDir = rig.homeDir!;
|
||||
// The response should contain the dynamic path which includes the home directory
|
||||
// and follows the .gemini/tmp/.../tracker structure.
|
||||
expect(result).toContain(homeDir);
|
||||
// The response should contain the dynamic path which follows the .gemini/tmp/.../tracker structure.
|
||||
expect(result).toMatch(/\.gemini\/tmp\/.*\/tracker/);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should update the tracker in the same turn as the task completion to save turns',
|
||||
params: {
|
||||
settings: { experimental: { taskTracker: true } },
|
||||
},
|
||||
files: FILES,
|
||||
prompt:
|
||||
'We have a bug in src/login.js: the password check is missing. Fix this bug. Then, create a new file src/auth.js that exports a simple verifyToken function. Please organize this into tasks and execute them.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall(TRACKER_CREATE_TASK_TOOL_NAME);
|
||||
await rig.waitForToolCall(TRACKER_UPDATE_TASK_TOOL_NAME);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Get the prompt ID of the fix for login.js
|
||||
const loginEditCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
(log.toolRequest.name === 'replace' ||
|
||||
log.toolRequest.name === 'write_file') &&
|
||||
log.toolRequest.args.includes('login.js'),
|
||||
);
|
||||
|
||||
expect(loginEditCalls.length).toBeGreaterThan(0);
|
||||
const loginEditPromptId =
|
||||
loginEditCalls[loginEditCalls.length - 1].toolRequest.prompt_id;
|
||||
|
||||
// Verify there is an update to the tracker in the exact same turn
|
||||
const parallelTrackerUpdates = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME &&
|
||||
log.toolRequest.prompt_id === loginEditPromptId,
|
||||
);
|
||||
|
||||
expect(
|
||||
parallelTrackerUpdates.length,
|
||||
'Expected tracker_update_task to be called in the same turn as the login.js fix',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import {
|
||||
WhisperModelManager,
|
||||
WhisperTranscriptionProvider,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import commandExists from 'command-exists';
|
||||
|
||||
describe('Voice Mode Integration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should be able to download tiny whisper model', async () => {
|
||||
// This test doesn't require the binary, only network access.
|
||||
// However, it's slow and downloads 75MB. We'll keep it for now but
|
||||
// wrap it in a try-catch to avoid failing on network flakiness in CI.
|
||||
const manager = new WhisperModelManager();
|
||||
const modelName = 'ggml-tiny.en.bin';
|
||||
|
||||
try {
|
||||
// Cleanup if already exists to ensure we actually test download
|
||||
const modelPath = manager.getModelPath(modelName);
|
||||
if (fs.existsSync(modelPath)) {
|
||||
fs.unlinkSync(modelPath);
|
||||
}
|
||||
|
||||
await manager.downloadModel(modelName);
|
||||
expect(fs.existsSync(modelPath)).toBe(true);
|
||||
expect(fs.statSync(modelPath).size).toBeGreaterThan(70 * 1024 * 1024); // ~75MB
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'Skipping whisper model download test due to error (possibly network):',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}, 300000); // 5 min timeout for download
|
||||
|
||||
it('should initialize WhisperTranscriptionProvider and handle process', async () => {
|
||||
// Skip this test if whisper-stream is not installed (typical for CI)
|
||||
try {
|
||||
await commandExists('whisper-stream');
|
||||
} catch {
|
||||
console.log(
|
||||
'Skipping Whisper transcription test: whisper-stream not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = new WhisperModelManager();
|
||||
const modelName = 'ggml-tiny.en.bin';
|
||||
if (!manager.isModelInstalled(modelName)) {
|
||||
await manager.downloadModel(modelName);
|
||||
}
|
||||
|
||||
const provider = new WhisperTranscriptionProvider({
|
||||
modelPath: manager.getModelPath(modelName),
|
||||
});
|
||||
|
||||
// Since we can't easily provide real mic input in CI,
|
||||
// we just verify it can start and be disconnected.
|
||||
await provider.connect();
|
||||
provider.disconnect();
|
||||
});
|
||||
});
|
||||
Generated
+10
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -18077,7 +18077,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18206,7 +18206,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18354,7 +18354,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18390,6 +18390,7 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
@@ -18664,7 +18665,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18679,7 +18680,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18710,7 +18711,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18742,7 +18743,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
+8
-11
@@ -9,6 +9,11 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import v8 from 'node:v8';
|
||||
import {
|
||||
RELAUNCH_EXIT_CODE,
|
||||
getSpawnConfig,
|
||||
getScriptArgs,
|
||||
} from './src/utils/processUtils.js';
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
@@ -74,18 +79,10 @@ async function run() {
|
||||
// --- Lightweight Parent Process / Daemon ---
|
||||
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
|
||||
|
||||
const nodeArgs: string[] = [...process.execArgv];
|
||||
const scriptArgs = process.argv.slice(2);
|
||||
|
||||
const scriptArgs = getScriptArgs();
|
||||
const memoryArgs = await getMemoryNodeArgs();
|
||||
nodeArgs.push(...memoryArgs);
|
||||
const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs);
|
||||
|
||||
const script = process.argv[1];
|
||||
nodeArgs.push(script);
|
||||
nodeArgs.push(...scriptArgs);
|
||||
|
||||
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
|
||||
const RELAUNCH_EXIT_CODE = 199;
|
||||
let latestAdminSettings: unknown = undefined;
|
||||
|
||||
// Prevent the parent process from exiting prematurely on signals.
|
||||
@@ -97,7 +94,7 @@ async function run() {
|
||||
const runner = () => {
|
||||
process.stdin.pause();
|
||||
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
const child = spawn(process.execPath, spawnArgs, {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
env: newEnv,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Agent Client Protocol (ACP) Implementation
|
||||
|
||||
This directory contains the implementation of the Agent Client Protocol (ACP)
|
||||
for the Gemini CLI. The ACP allows external clients (like IDE extensions) to
|
||||
communicate with the Gemini CLI agent over a structured JSON-RPC based protocol.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Following Phase 1 of the modularization refactor, the ACP client is organized
|
||||
into the following specialized modules, all sharing the `acp` prefix for
|
||||
consistency:
|
||||
|
||||
- **[acpStdioTransport.ts](./acpStdioTransport.ts)**: Handles raw I/O. It sets
|
||||
up the Web streams for standard input/output and creates the
|
||||
`AgentSideConnection` using line-delimited JSON (ndjson).
|
||||
- **[acpRpcDispatcher.ts](./acpRpcDispatcher.ts)**: Contains the `GeminiAgent`
|
||||
class. This is the main entry point for incoming JSON-RPC messages. It
|
||||
implements the protocol methods and delegates session-specific work to the
|
||||
manager and individual sessions.
|
||||
- **[acpSessionManager.ts](./acpSessionManager.ts)**: Manages multi-session
|
||||
state. It handles session creation (`newSession`), loading (`loadSession`),
|
||||
and configuration, isolating session state from the RPC routing.
|
||||
- **[acpSession.ts](./acpSession.ts)**: Manages individual active chat sessions.
|
||||
It handles prompt execution, `@path` file resolution, tool execution, command
|
||||
interception, and streaming updates back to the client.
|
||||
- **[acpUtils.ts](./acpUtils.ts)**: Contains shared helper functions, type
|
||||
mappers (e.g., mapping internal tool kinds to ACP kinds), and Zod schemas used
|
||||
across the modules.
|
||||
- **[acpErrors.ts](./acpErrors.ts)**: Centralized error handling and mapping to
|
||||
ACP-compliant error codes.
|
||||
- **[acpCommandHandler.ts](./acpCommandHandler.ts)**: Handles interception and
|
||||
execution of slash commands (e.g., `/memory`, `/init`) sent via ACP prompts.
|
||||
- **[acpFileSystemService.ts](./acpFileSystemService.ts)**: Provides access to
|
||||
the file system restricted by the workspace boundaries and permissions.
|
||||
|
||||
## Development Instructions
|
||||
|
||||
### Running Tests
|
||||
|
||||
Tests are co-located with the source files:
|
||||
|
||||
- `acpRpcDispatcher.test.ts`: Tests for initialization, authentication, and
|
||||
handler delegation.
|
||||
- `acpSessionManager.test.ts`: Tests for session lifecycle and configuration.
|
||||
- `acpSession.test.ts`: Tests for prompt loops, tool execution, and @path
|
||||
resolution.
|
||||
- `acpResume.test.ts`: Integration tests for loading/resuming sessions.
|
||||
|
||||
To run specific tests, use Vitest with the workspace filter:
|
||||
|
||||
```bash
|
||||
# General pattern
|
||||
npm test -w @google/gemini-cli -- src/acp/<test-file-name>.ts
|
||||
|
||||
# Example
|
||||
npm test -w @google/gemini-cli -- src/acp/acpRpcDispatcher.test.ts
|
||||
```
|
||||
|
||||
Note: You may need to ensure your environment has Node available. If running in
|
||||
a restricted environment, try sourcing NVM first:
|
||||
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && nvm use default && npm test -w @google/gemini-cli -- src/acp/acpSession.test.ts
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
|
||||
- **New RPC Method**: Add the method to `GeminiAgent` in `acpRpcDispatcher.ts`
|
||||
and register it in the `AgentSideConnection` setup if necessary.
|
||||
- **Session State**: If a feature requires storing state across turns within a
|
||||
session, add it to the `Session` class in `acpSession.ts`.
|
||||
- **Protocol Helpers**: Add any new mapping or serialization logic to
|
||||
`acpUtils.ts`.
|
||||
|
||||
### Coding Conventions
|
||||
|
||||
- **Imports**: Use specific imports and do not import across package boundaries
|
||||
using relative paths.
|
||||
- **License Headers**: All new files must include the Apache-2.0 license header.
|
||||
- **Type Safety**: Avoid using `any` assertions. Use Zod schemas to validate
|
||||
untrusted input from the protocol.
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandHandler } from './commandHandler.js';
|
||||
import { CommandHandler } from './acpCommandHandler.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('CommandHandler', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
afterEach,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
import { AcpFileSystemService } from './acpFileSystemService.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
import type { FileSystemService } from '@google/gemini-cli-core';
|
||||
import os from 'node:os';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type Mocked,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './acpClient.js';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
ApprovalMode,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../utils/sessionUtils.js';
|
||||
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { waitFor } from '../test-utils/async.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
@@ -106,6 +107,9 @@ describe('GeminiAgent Session Resume', () => {
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
toolRegistry: {
|
||||
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
|
||||
},
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
@@ -170,11 +174,6 @@ describe('GeminiAgent Session Resume', () => {
|
||||
],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockConfig as any).toolRegistry = {
|
||||
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
|
||||
};
|
||||
|
||||
(SessionSelector as unknown as Mock).mockImplementation(() => ({
|
||||
resolveSession: vi.fn().mockResolvedValue({
|
||||
sessionData,
|
||||
@@ -240,7 +239,7 @@ describe('GeminiAgent Session Resume', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
await waitFor(() => {
|
||||
// User message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
type Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { loadSettings, SettingScope } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('GeminiAgent - RPC Dispatcher', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let agent: GeminiAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
startChat: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus,
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
} as unknown as Storage,
|
||||
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'login_with_google' } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
|
||||
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
|
||||
});
|
||||
|
||||
it('should initialize correctly', async () => {
|
||||
const response = await agent.initialize({
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
protocolVersion: 1,
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
|
||||
expect(response.authMethods).toHaveLength(4);
|
||||
const gatewayAuth = response.authMethods?.find(
|
||||
(m) => m.id === AuthType.GATEWAY,
|
||||
);
|
||||
expect(gatewayAuth?._meta).toEqual({
|
||||
gateway: {
|
||||
protocol: 'google',
|
||||
restartRequired: 'false',
|
||||
},
|
||||
});
|
||||
const geminiAuth = response.authMethods?.find(
|
||||
(m) => m.id === AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(geminiAuth?._meta).toEqual({
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
});
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should authenticate correctly', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.LOGIN_WITH_GOOGLE,
|
||||
});
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should authenticate correctly with api-key in _meta', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.USE_GEMINI,
|
||||
_meta: {
|
||||
'api-key': 'test-api-key',
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest);
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
'test-api-key',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
});
|
||||
|
||||
it('should authenticate correctly with gateway method', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.GATEWAY,
|
||||
_meta: {
|
||||
gateway: {
|
||||
baseUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest);
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.GATEWAY,
|
||||
undefined,
|
||||
'https://example.com',
|
||||
{ Authorization: 'Bearer token' },
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.GATEWAY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw acp.RequestError when gateway payload is malformed', async () => {
|
||||
await expect(
|
||||
agent.authenticate({
|
||||
methodId: AuthType.GATEWAY,
|
||||
_meta: {
|
||||
gateway: {
|
||||
baseUrl: 123,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest),
|
||||
).rejects.toThrow(/Malformed gateway payload/);
|
||||
});
|
||||
|
||||
it('should cancel a session', async () => {
|
||||
const mockSession = {
|
||||
cancelPendingPrompt: vi.fn(),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
await agent.cancel({ sessionId: 'test-session-id' });
|
||||
|
||||
expect(mockSession.cancelPendingPrompt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when cancelling non-existent session', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow(
|
||||
'Session not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should delegate prompt to session', async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [],
|
||||
});
|
||||
|
||||
expect(mockSession.prompt).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should delegate setMode to session', async () => {
|
||||
const mockSession = {
|
||||
setMode: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.setSessionMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId: 'plan',
|
||||
});
|
||||
|
||||
expect(mockSession.setMode).toHaveBeenCalledWith('plan');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error when setting mode on non-existent session', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
agent.setSessionMode({
|
||||
sessionId: 'unknown',
|
||||
modeId: 'plan',
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
|
||||
it('should delegate setModel to session (unstable)', async () => {
|
||||
const mockSession = {
|
||||
setModel: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.unstable_setSessionModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'gemini-2.0-pro-exp',
|
||||
});
|
||||
|
||||
expect(mockSession.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error when setting model on non-existent session (unstable)', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
agent.unstable_setSessionModel({
|
||||
sessionId: 'unknown',
|
||||
modelId: 'gemini-2.0-pro-exp',
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type AgentLoopContext,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getVersion,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { z } from 'zod';
|
||||
import { SettingScope, type LoadedSettings } from '../config/settings.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
import { AcpSessionManager, type AuthDetails } from './acpSessionManager.js';
|
||||
import { hasMeta } from './acpUtils.js';
|
||||
|
||||
export class GeminiAgent {
|
||||
private apiKey: string | undefined;
|
||||
private baseUrl: string | undefined;
|
||||
private customHeaders: Record<string, string> | undefined;
|
||||
private sessionManager: AcpSessionManager;
|
||||
|
||||
constructor(
|
||||
private context: AgentLoopContext,
|
||||
private settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
connection: acp.AgentSideConnection,
|
||||
) {
|
||||
this.sessionManager = new AcpSessionManager(settings, argv, connection);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.sessionManager.dispose();
|
||||
}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
if (args.clientCapabilities) {
|
||||
this.sessionManager.setClientCapabilities(args.clientCapabilities);
|
||||
}
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
name: 'Log in with Google',
|
||||
description: 'Log in with your Google account',
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_GEMINI,
|
||||
name: 'Gemini API key',
|
||||
description: 'Use an API key with Gemini Developer API',
|
||||
_meta: {
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_VERTEX_AI,
|
||||
name: 'Vertex AI',
|
||||
description: 'Use an API key with Vertex AI GenAI API',
|
||||
},
|
||||
{
|
||||
id: AuthType.GATEWAY,
|
||||
name: 'AI API Gateway',
|
||||
description: 'Use a custom AI API Gateway',
|
||||
_meta: {
|
||||
gateway: {
|
||||
protocol: 'google',
|
||||
restartRequired: 'false',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await this.context.config.initialize();
|
||||
const version = await getVersion();
|
||||
return {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
authMethods,
|
||||
agentInfo: {
|
||||
name: 'gemini-cli',
|
||||
title: 'Gemini CLI',
|
||||
version,
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
|
||||
const { methodId } = req;
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
|
||||
// Only clear credentials when switching to a different auth method
|
||||
if (selectedAuthType && selectedAuthType !== method) {
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
// Check for api-key in _meta
|
||||
const meta = hasMeta(req) ? req._meta : undefined;
|
||||
const apiKey =
|
||||
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
|
||||
|
||||
// Refresh auth with the requested method
|
||||
// This will reuse existing credentials if they're valid,
|
||||
// or perform new authentication if needed
|
||||
try {
|
||||
if (apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
// Extract gateway details if present
|
||||
const gatewaySchema = z.object({
|
||||
baseUrl: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
let baseUrl: string | undefined;
|
||||
let headers: Record<string, string> | undefined;
|
||||
|
||||
if (meta?.['gateway']) {
|
||||
const result = gatewaySchema.safeParse(meta['gateway']);
|
||||
if (result.success) {
|
||||
baseUrl = result.data.baseUrl;
|
||||
headers = result.data.headers;
|
||||
} else {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Malformed gateway payload: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl;
|
||||
this.customHeaders = headers;
|
||||
|
||||
await this.context.config.refreshAuth(
|
||||
method,
|
||||
apiKey ?? this.apiKey,
|
||||
baseUrl,
|
||||
headers,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
|
||||
}
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthDetails(): AuthDetails {
|
||||
return {
|
||||
apiKey: this.apiKey,
|
||||
baseUrl: this.baseUrl,
|
||||
customHeaders: this.customHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(
|
||||
params: acp.NewSessionRequest,
|
||||
): Promise<acp.NewSessionResponse> {
|
||||
return this.sessionManager.newSession(params, this.getAuthDetails());
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
params: acp.LoadSessionRequest,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
return this.sessionManager.loadSession(params, this.getAuthDetails());
|
||||
}
|
||||
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
await session.cancelPendingPrompt();
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
async setSessionMode(
|
||||
params: acp.SetSessionModeRequest,
|
||||
): Promise<acp.SetSessionModeResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setMode(params.modeId);
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: acp.SetSessionModelRequest,
|
||||
): Promise<acp.SetSessionModelResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setModel(params.modelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { Session } from './acpSession.js';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
ReadManyFilesTool,
|
||||
type GeminiChat,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
type GitService,
|
||||
InvalidStreamError,
|
||||
GeminiEventType,
|
||||
type ServerGeminiStreamEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { type Part, FinishReason } from '@google/genai';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { CommandHandler } from './acpCommandHandler.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:path')>();
|
||||
return {
|
||||
...actual,
|
||||
resolve: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
'@google/gemini-cli-core',
|
||||
async (
|
||||
importOriginal: () => Promise<typeof import('@google/gemini-cli-core')>,
|
||||
) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
updatePolicy: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn(),
|
||||
logToolCall: vi.fn(),
|
||||
processSingleFileContent: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
async function* createMockStream(
|
||||
items: readonly ServerGeminiStreamEvent[],
|
||||
): AsyncGenerator<ServerGeminiStreamEvent> {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
|
||||
yield {
|
||||
type: GeminiEventType.Finished,
|
||||
value: {
|
||||
reason: FinishReason.STOP,
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Session', () => {
|
||||
let mockChat: Mocked<GeminiChat>;
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let session: Session;
|
||||
let mockToolRegistry: { getTool: Mock };
|
||||
let mockTool: { kind: string; build: Mock };
|
||||
let mockMessageBus: Mocked<MessageBus>;
|
||||
let mockSendMessageStream: MockInstance<
|
||||
(
|
||||
request: Part[],
|
||||
signal: AbortSignal,
|
||||
promptId: string,
|
||||
) => AsyncGenerator<ServerGeminiStreamEvent>
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Mocked<GeminiChat>;
|
||||
mockTool = {
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(null),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
}),
|
||||
};
|
||||
mockToolRegistry = {
|
||||
getTool: vi.fn().mockReturnValue(mockTool),
|
||||
};
|
||||
mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as Mocked<MessageBus>;
|
||||
mockSendMessageStream = vi.fn();
|
||||
mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModelRouterService: vi.fn().mockReturnValue({
|
||||
route: vi.fn().mockResolvedValue({ model: 'resolved-model' }),
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
shouldIgnoreFile: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue({}),
|
||||
getFileSystemService: vi.fn().mockReturnValue({}),
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
setApprovalMode: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getGitService: vi.fn().mockResolvedValue({} as GitService),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
getMaxSessionTurns: vi.fn().mockReturnValue(-1),
|
||||
geminiClient: {
|
||||
sendMessageStream: mockSendMessageStream,
|
||||
getChat: vi.fn().mockReturnValue(mockChat),
|
||||
},
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
get toolRegistry() {
|
||||
return mockToolRegistry;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
session = new Session('session-1', mockChat, mockConfig, mockConnection, {
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings);
|
||||
|
||||
(ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should send available commands', async () => {
|
||||
await session.sendAvailableCommands();
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should await MCP initialization before processing a prompt', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hi',
|
||||
},
|
||||
]);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'test' }],
|
||||
});
|
||||
|
||||
expect(mockConfig.waitForMcpInit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should handle prompt with text response', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
},
|
||||
]);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockSendMessageStream).toHaveBeenCalled();
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello' },
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should pass current session information directly onto geminiClient.sendMessageStream', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
},
|
||||
]);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockSendMessageStream).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([{ text: 'Hi' }]),
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (InvalidStreamError)', async () => {
|
||||
const error = new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT');
|
||||
mockSendMessageStream.mockImplementation(() => {
|
||||
async function* errorGen(): AsyncGenerator<
|
||||
ServerGeminiStreamEvent,
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
yield* [];
|
||||
throw error;
|
||||
}
|
||||
return errorGen();
|
||||
});
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with no finish reason (InvalidStreamError)', async () => {
|
||||
const error = new InvalidStreamError(
|
||||
'No finish reason',
|
||||
'NO_FINISH_REASON',
|
||||
);
|
||||
mockSendMessageStream.mockImplementation(() => {
|
||||
async function* errorGen(): AsyncGenerator<
|
||||
ServerGeminiStreamEvent,
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
yield* [];
|
||||
throw error;
|
||||
}
|
||||
return errorGen();
|
||||
});
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
(session as unknown as { commandHandler: CommandHandler })
|
||||
.commandHandler,
|
||||
'handleCommand',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/memory view' }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
expect(handleCommandSpy).toHaveBeenCalledWith(
|
||||
'/memory view',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tool calls', async () => {
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: { foo: 'bar' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Result',
|
||||
},
|
||||
]);
|
||||
|
||||
mockSendMessageStream
|
||||
.mockReturnValueOnce(stream1)
|
||||
.mockReturnValueOnce(stream2);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('test_tool');
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle tool call permission request', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: 'proceed_once',
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
|
||||
mockSendMessageStream
|
||||
.mockReturnValueOnce(stream1)
|
||||
.mockReturnValueOnce(stream2);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalled();
|
||||
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle @path resolution', async () => {
|
||||
(path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt');
|
||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
});
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [
|
||||
{ type: 'text', text: 'Read' },
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file://file.txt',
|
||||
mimeType: 'text/plain',
|
||||
name: 'file.txt',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(path.resolve).toHaveBeenCalled();
|
||||
expect(fs.stat).toHaveBeenCalled();
|
||||
expect(mockSendMessageStream).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('Content from @file.txt'),
|
||||
}),
|
||||
]),
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rate limit error', async () => {
|
||||
const error = new Error('Rate limit');
|
||||
const customError = error as { status?: number; message?: string };
|
||||
customError.status = 429;
|
||||
|
||||
mockSendMessageStream.mockImplementation(() => {
|
||||
async function* errorGen(): AsyncGenerator<
|
||||
ServerGeminiStreamEvent,
|
||||
void,
|
||||
unknown
|
||||
> {
|
||||
yield* [];
|
||||
throw customError;
|
||||
}
|
||||
return errorGen();
|
||||
});
|
||||
|
||||
await expect(
|
||||
session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 429,
|
||||
message: 'Rate limit exceeded. Try again later.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing tool', async () => {
|
||||
mockToolRegistry.getTool.mockReturnValue(undefined);
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
callId: 'call-1',
|
||||
name: 'unknown_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.Content,
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
|
||||
mockSendMessageStream
|
||||
.mockReturnValueOnce(stream1)
|
||||
.mockReturnValueOnce(stream2);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle GeminiEventType.LoopDetected', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.LoopDetected,
|
||||
},
|
||||
]);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Trigger Loop Simulation' }],
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe('max_turn_requests');
|
||||
});
|
||||
|
||||
it('should handle GeminiEventType.ContextWindowWillOverflow', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.ContextWindowWillOverflow,
|
||||
value: { estimatedRequestTokenCount: 1000, remainingTokenCount: 200 },
|
||||
},
|
||||
]);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Trigger Overflow Simulation' }],
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe('max_tokens');
|
||||
});
|
||||
|
||||
it('should handle GeminiEventType.MaxSessionTurns', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: GeminiEventType.MaxSessionTurns,
|
||||
},
|
||||
]);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Trigger Safety Limits' }],
|
||||
});
|
||||
|
||||
expect(result.stopReason).toBe('max_turn_requests');
|
||||
});
|
||||
|
||||
it('should send sessionUpdate when approval mode changes', async () => {
|
||||
const { coreEvents, CoreEvent, ApprovalMode } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
|
||||
coreEvents.emit(CoreEvent.ApprovalModeChanged, {
|
||||
sessionId: 'session-1',
|
||||
mode: ApprovalMode.PLAN,
|
||||
});
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `[MODE_UPDATE] ${ApprovalMode.PLAN}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpSessionManager } from './acpSessionManager.js';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
type Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const startAutoMemoryIfEnabledMock = vi.fn();
|
||||
vi.mock('../utils/autoMemory.js', () => ({
|
||||
startAutoMemoryIfEnabled: (config: Config) =>
|
||||
startAutoMemoryIfEnabledMock(config),
|
||||
}));
|
||||
|
||||
describe('AcpSessionManager', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let manager: AcpSessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
startChat: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus,
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
} as unknown as Storage,
|
||||
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'login_with_google' } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
|
||||
manager = new AcpSessionManager(mockSettings, mockArgv, mockConnection);
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: () => 'test-session-id',
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should create a new session', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.sessionId).toBe('test-session-id');
|
||||
expect(loadCliConfig).toHaveBeenCalled();
|
||||
expect(mockConfig.initialize).toHaveBeenCalled();
|
||||
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
|
||||
|
||||
// Verify deferred call (sendAvailableCommands)
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return modes without plan mode when plan is disabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue('default');
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
|
||||
{
|
||||
id: 'autoEdit',
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
|
||||
],
|
||||
currentModeId: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include preview models when user has access', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'auto-gemini-3',
|
||||
name: expect.stringContaining('Auto'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'gemini-3.1-flash-lite-preview',
|
||||
name: 'gemini-3.1-flash-lite-preview',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return modes with plan mode when plan is enabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan');
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
|
||||
{
|
||||
id: 'autoEdit',
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
|
||||
{ id: 'plan', name: 'Plan', description: 'Read-only mode' },
|
||||
],
|
||||
currentModeId: 'plan',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail session creation if Gemini API key is missing', async () => {
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.USE_GEMINI } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Gemini API key is missing or not configured.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a new session with mcp servers', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
const mcpServers = [
|
||||
{
|
||||
name: 'test-server',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: [{ name: 'KEY', value: 'VALUE' }],
|
||||
},
|
||||
];
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(loadCliConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mcpServers: expect.objectContaining({
|
||||
'test-server': expect.objectContaining({
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { KEY: 'VALUE' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
'test-session-id',
|
||||
mockArgv,
|
||||
{ cwd: '/tmp' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authentication failure gracefully', async () => {
|
||||
mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
await expect(
|
||||
manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Auth failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize file system service if client supports it', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
manager.setClientCapabilities({
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
});
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mockConfig.setFileSystemService).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start auto memory for new ACP sessions', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(startAutoMemoryIfEnabledMock).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Config,
|
||||
AuthType,
|
||||
MCPServerConfig,
|
||||
debugLogger,
|
||||
startupProfiler,
|
||||
convertSessionToClientHistory,
|
||||
createPolicyUpdater,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { loadSettings, type LoadedSettings } from '../config/settings.js';
|
||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||
import { Session } from './acpSession.js';
|
||||
import { AcpFileSystemService } from './acpFileSystemService.js';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
import { buildAvailableModels, buildAvailableModes } from './acpUtils.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
|
||||
|
||||
export interface AuthDetails {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class AcpSessionManager {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
|
||||
constructor(
|
||||
private settings: LoadedSettings,
|
||||
private argv: CliArgs,
|
||||
private connection: acp.AgentSideConnection,
|
||||
) {}
|
||||
|
||||
setClientCapabilities(capabilities: acp.ClientCapabilities) {
|
||||
this.clientCapabilities = capabilities;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): Session | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const session of this.sessions.values()) {
|
||||
session.dispose();
|
||||
}
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
async newSession(
|
||||
{ cwd, mcpServers }: acp.NewSessionRequest,
|
||||
authDetails: AuthDetails,
|
||||
): Promise<acp.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
const config = await this.newSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const authType =
|
||||
loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI;
|
||||
|
||||
let isAuthenticated = false;
|
||||
let authErrorMessage = '';
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
authType,
|
||||
authDetails.apiKey,
|
||||
authDetails.baseUrl,
|
||||
authDetails.customHeaders,
|
||||
);
|
||||
isAuthenticated = true;
|
||||
|
||||
// Extra validation for Gemini API key
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI &&
|
||||
(!contentGeneratorConfig || !contentGeneratorConfig.apiKey)
|
||||
) {
|
||||
isAuthenticated = false;
|
||||
authErrorMessage = 'Gemini API key is missing or not configured.';
|
||||
}
|
||||
} catch (e) {
|
||||
isAuthenticated = false;
|
||||
authErrorMessage = getAcpErrorMessage(e);
|
||||
debugLogger.error(
|
||||
`Authentication failed: ${e instanceof Error ? e.stack : e}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw new acp.RequestError(
|
||||
-32000,
|
||||
authErrorMessage || 'Authentication required.',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
const chat = await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
sessionId,
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
{ sessionId, cwd, mcpServers }: acp.LoadSessionRequest,
|
||||
authDetails: AuthDetails,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
const config = await this.initializeSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
authDetails,
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config.storage);
|
||||
|
||||
const { sessionData, sessionPath } =
|
||||
await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
await geminiClient.initialize();
|
||||
await geminiClient.resumeChat(clientHistory, {
|
||||
conversation: sessionData,
|
||||
filePath: sessionPath,
|
||||
});
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
geminiClient.getChat(),
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const existingSession = this.sessions.get(sessionId);
|
||||
if (existingSession) {
|
||||
existingSession.dispose();
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Stream history back to client
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
private async initializeSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
authDetails: AuthDetails,
|
||||
): Promise<Config> {
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
|
||||
// This satisfies the security requirement to verify the user before executing
|
||||
// potentially unsafe server definitions.
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
selectedAuthType,
|
||||
authDetails.apiKey,
|
||||
authDetails.baseUrl,
|
||||
authDetails.customHeaders,
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 3. Set the ACP FileSystemService (if supported) before config initialization
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
// 4. Now that we are authenticated, it is safe to initialize the config
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
loadedSettings?: LoadedSettings,
|
||||
): Promise<Config> {
|
||||
const currentSettings = loadedSettings || this.settings;
|
||||
const mergedMcpServers = { ...currentSettings.merged.mcpServers };
|
||||
|
||||
for (const server of mcpServers) {
|
||||
if (
|
||||
'type' in server &&
|
||||
(server.type === 'sse' || server.type === 'http')
|
||||
) {
|
||||
// HTTP or SSE MCP server
|
||||
const headers = Object.fromEntries(
|
||||
server.headers.map(({ name, value }) => [name, value]),
|
||||
);
|
||||
mergedMcpServers[server.name] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
server.type === 'sse' ? server.url : undefined, // url (sse)
|
||||
server.type === 'http' ? server.url : undefined, // httpUrl
|
||||
headers,
|
||||
);
|
||||
} else if ('command' in server) {
|
||||
// Stdio MCP server
|
||||
const env: Record<string, string> = {};
|
||||
for (const { name: envName, value } of server.env) {
|
||||
env[envName] = value;
|
||||
}
|
||||
mergedMcpServers[server.name] = new MCPServerConfig(
|
||||
server.command,
|
||||
server.args,
|
||||
env,
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
...currentSettings.merged,
|
||||
mcpServers: mergedMcpServers,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
createPolicyUpdater(
|
||||
config.getPolicyEngine(),
|
||||
config.messageBus,
|
||||
config.storage,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Config, createWorkingStdio } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
|
||||
export async function runAcpClient(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
) {
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
const stdout = Writable.toWeb(workingStdout) as WritableStream;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
||||
|
||||
const stream = acp.ndJsonStream(stdout, stdin);
|
||||
const connection = new acp.AgentSideConnection(
|
||||
(connection) => new GeminiAgent(config, settings, argv, connection),
|
||||
stream,
|
||||
);
|
||||
|
||||
// SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes.
|
||||
// We must explicitly await the connection close to flush telemetry.
|
||||
// Use finally() to ensure cleanup runs even on stream errors.
|
||||
await connection.closed.finally(runExitCleanup);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Config,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
Kind,
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import { z } from 'zod';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
|
||||
export function hasMeta(
|
||||
obj: unknown,
|
||||
): obj is { _meta?: Record<string, unknown> } {
|
||||
return typeof obj === 'object' && obj !== null && '_meta' in obj;
|
||||
}
|
||||
|
||||
export const RequestPermissionResponseSchema = z.object({
|
||||
outcome: z.discriminatedUnion('outcome', [
|
||||
z.object({ outcome: z.literal('cancelled') }),
|
||||
z.object({
|
||||
outcome: z.literal('selected'),
|
||||
optionId: z.string(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export function toToolCallContent(
|
||||
toolResult: ToolResult,
|
||||
): acp.ToolCallContent | null {
|
||||
if (toolResult.error?.message) {
|
||||
throw new Error(toolResult.error.message);
|
||||
}
|
||||
|
||||
if (toolResult.returnDisplay) {
|
||||
if (typeof toolResult.returnDisplay === 'string') {
|
||||
return {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: toolResult.returnDisplay },
|
||||
};
|
||||
} else {
|
||||
if ('fileName' in toolResult.returnDisplay) {
|
||||
return {
|
||||
type: 'diff',
|
||||
path:
|
||||
toolResult.returnDisplay.filePath ??
|
||||
toolResult.returnDisplay.fileName,
|
||||
oldText: toolResult.returnDisplay.originalContent,
|
||||
newText: toolResult.returnDisplay.newContent,
|
||||
_meta: {
|
||||
kind: !toolResult.returnDisplay.originalContent
|
||||
? 'add'
|
||||
: toolResult.returnDisplay.newContent === ''
|
||||
? 'delete'
|
||||
: 'modify',
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const basicPermissionOptions = [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: 'Allow',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: 'Reject',
|
||||
kind: 'reject_once',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
enablePermanentToolApproval: boolean = false,
|
||||
): acp.PermissionOption[] {
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
|
||||
if (!disableAlwaysAllow) {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow this command for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: 'Allow all server tools for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: 'Allow tool for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow tool for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
// askuser and exit_plan_mode don't need "always allow" options
|
||||
break;
|
||||
default:
|
||||
// No "always allow" options for other types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
options.push(...basicPermissionOptions);
|
||||
|
||||
// Exhaustive check
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
case 'exec':
|
||||
case 'mcp':
|
||||
case 'info':
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
case 'sandbox_expansion':
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function toAcpToolKind(kind: Kind): acp.ToolKind {
|
||||
switch (kind) {
|
||||
case Kind.Read:
|
||||
case Kind.Edit:
|
||||
case Kind.Execute:
|
||||
case Kind.Search:
|
||||
case Kind.Delete:
|
||||
case Kind.Move:
|
||||
case Kind.Think:
|
||||
case Kind.Fetch:
|
||||
case Kind.SwitchMode:
|
||||
case Kind.Other:
|
||||
return kind as acp.ToolKind;
|
||||
case Kind.Agent:
|
||||
return 'think';
|
||||
case Kind.Plan:
|
||||
case Kind.Communicate:
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
|
||||
const modes: acp.SessionMode[] = [
|
||||
{
|
||||
id: ApprovalMode.DEFAULT,
|
||||
name: 'Default',
|
||||
description: 'Prompts for approval',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.AUTO_EDIT,
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.YOLO,
|
||||
name: 'YOLO',
|
||||
description: 'Auto-approves all tools',
|
||||
},
|
||||
];
|
||||
|
||||
if (isPlanEnabled) {
|
||||
modes.push({
|
||||
id: ApprovalMode.PLAN,
|
||||
name: 'Plan',
|
||||
description: 'Read-only mode',
|
||||
});
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
export function buildAvailableModels(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): {
|
||||
availableModels: Array<{
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
currentModelId: string;
|
||||
} {
|
||||
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
) {
|
||||
const options = config.getModelConfigService().getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
});
|
||||
|
||||
return {
|
||||
availableModels: options,
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const mainOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
mainOptions.unshift({
|
||||
value: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
description: useGemini31
|
||||
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
|
||||
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
});
|
||||
}
|
||||
|
||||
const manualOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
: PREVIEW_GEMINI_MODEL;
|
||||
|
||||
const previewProValue = useCustomToolModel
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
const previewOptions = [
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (useGemini31FlashLite) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
});
|
||||
}
|
||||
|
||||
manualOptions.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
const scaleOptions = (
|
||||
options: Array<{ value: string; title: string; description?: string }>,
|
||||
) =>
|
||||
options.map((o) => ({
|
||||
modelId: o.value,
|
||||
name: o.title,
|
||||
description: o.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableModels: [
|
||||
...scaleOptions(mainOptions),
|
||||
...scaleOptions(manualOptions),
|
||||
],
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { CommandContext } from './types.js';
|
||||
import {
|
||||
DisableExtensionCommand,
|
||||
UninstallExtensionCommand,
|
||||
} from './extensions.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
|
||||
const mockGetErrorMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: mockGetErrorMessage,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../config/extension-manager.js', () => {
|
||||
class MockExtensionManager {
|
||||
disableExtension = vi.fn(async () => undefined);
|
||||
uninstallExtension = vi.fn(async () => undefined);
|
||||
getExtensions = vi.fn(() => []);
|
||||
}
|
||||
|
||||
return {
|
||||
ExtensionManager: MockExtensionManager,
|
||||
inferInstallMetadata: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
type TestExtensionManager = InstanceType<typeof ExtensionManager> & {
|
||||
disableExtension: ReturnType<typeof vi.fn>;
|
||||
uninstallExtension: ReturnType<typeof vi.fn>;
|
||||
getExtensions: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function createExtensionManager(): TestExtensionManager {
|
||||
return new ExtensionManager({} as never) as TestExtensionManager;
|
||||
}
|
||||
|
||||
function createContext(extensionLoader: unknown): CommandContext {
|
||||
return {
|
||||
agentContext: {
|
||||
config: {
|
||||
getExtensionLoader: vi.fn().mockReturnValue(extensionLoader),
|
||||
},
|
||||
} as unknown as CommandContext['agentContext'],
|
||||
settings: {} as CommandContext['settings'],
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ACP extensions error paths', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetErrorMessage.mockImplementation((error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when disabling fails', async () => {
|
||||
const command = new DisableExtensionCommand();
|
||||
const extensionManager = createExtensionManager();
|
||||
extensionManager.disableExtension.mockRejectedValue(
|
||||
new Error('Extension not found.'),
|
||||
);
|
||||
const context = createContext(extensionManager);
|
||||
|
||||
const result = await command.execute(context, ['missing-ext']);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'extensions disable',
|
||||
data: 'Failed to disable "missing-ext": Extension not found.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when uninstalling a non-existent extension', async () => {
|
||||
const command = new UninstallExtensionCommand();
|
||||
const extensionManager = createExtensionManager();
|
||||
extensionManager.uninstallExtension.mockRejectedValue(
|
||||
new Error('Extension not found.'),
|
||||
);
|
||||
const context = createContext(extensionManager);
|
||||
|
||||
const result = await command.execute(context, ['non-existent-ext']);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'extensions uninstall',
|
||||
data: 'Failed to uninstall extension "non-existent-ext": Extension not found.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
addMemory,
|
||||
listInboxMemoryPatches,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
listMemoryFiles,
|
||||
@@ -129,7 +130,7 @@ export class AddMemoryCommand implements Command {
|
||||
export class InboxMemoryCommand implements Command {
|
||||
readonly name = 'memory inbox';
|
||||
readonly description =
|
||||
'Lists skills extracted from past sessions that are pending review.';
|
||||
'Lists memory items extracted from past sessions that are pending review.';
|
||||
|
||||
async execute(
|
||||
context: CommandContext,
|
||||
@@ -142,12 +143,17 @@ export class InboxMemoryCommand implements Command {
|
||||
};
|
||||
}
|
||||
|
||||
const [skills, patches] = await Promise.all([
|
||||
const [skills, patches, memoryPatches] = await Promise.all([
|
||||
listInboxSkills(context.agentContext.config),
|
||||
listInboxPatches(context.agentContext.config),
|
||||
listInboxMemoryPatches(context.agentContext.config),
|
||||
]);
|
||||
|
||||
if (skills.length === 0 && patches.length === 0) {
|
||||
if (
|
||||
skills.length === 0 &&
|
||||
patches.length === 0 &&
|
||||
memoryPatches.length === 0
|
||||
) {
|
||||
return { name: this.name, data: 'No items in inbox.' };
|
||||
}
|
||||
|
||||
@@ -165,8 +171,19 @@ export class InboxMemoryCommand implements Command {
|
||||
: '';
|
||||
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
|
||||
}
|
||||
for (const memoryPatch of memoryPatches) {
|
||||
const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
|
||||
const date = memoryPatch.extractedAt
|
||||
? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
|
||||
: '';
|
||||
const sourceCount = memoryPatch.sourceFiles.length;
|
||||
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
|
||||
lines.push(
|
||||
`- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const total = skills.length + patches.length;
|
||||
const total = skills.length + patches.length + memoryPatches.length;
|
||||
return {
|
||||
name: this.name,
|
||||
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import {
|
||||
getCheckpointInfoList,
|
||||
getToolCallDataSchema,
|
||||
isNodeError,
|
||||
performRestore,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getCheckpointInfoList: vi.fn(),
|
||||
getToolCallDataSchema: vi.fn(),
|
||||
isNodeError: vi.fn(),
|
||||
performRestore: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('RestoreCommand', () => {
|
||||
let context: CommandContext;
|
||||
let restoreCommand: RestoreCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
restoreCommand = new RestoreCommand();
|
||||
context = {
|
||||
agentContext: {
|
||||
config: {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/checkpoints'),
|
||||
},
|
||||
},
|
||||
},
|
||||
git: {},
|
||||
sendMessage: vi.fn(),
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('delegates to list behavior when invoked without args', async () => {
|
||||
const listExecuteSpy = vi
|
||||
.spyOn(ListCheckpointsCommand.prototype, 'execute')
|
||||
.mockResolvedValue({
|
||||
name: 'restore list',
|
||||
data: 'list data',
|
||||
});
|
||||
|
||||
const response = await restoreCommand.execute(context, []);
|
||||
|
||||
expect(listExecuteSpy).toHaveBeenCalledWith(context);
|
||||
expect(response).toEqual({
|
||||
name: 'restore list',
|
||||
data: 'list data',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns checkpointing-disabled message when disabled', async () => {
|
||||
(
|
||||
context.agentContext.config.getCheckpointingEnabled as Mock
|
||||
).mockReturnValue(false);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['checkpoint1']);
|
||||
|
||||
expect(response.data).toContain('Checkpointing is not enabled');
|
||||
});
|
||||
|
||||
it('returns file-not-found message for missing checkpoint', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(isNodeError).mockReturnValue(true);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['missing']);
|
||||
|
||||
expect(response.data).toBe('File not found: missing.json');
|
||||
});
|
||||
|
||||
it('handles checkpoint filename already ending in .json', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(isNodeError).mockReturnValue(true);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['existing.json']);
|
||||
|
||||
expect(response.data).toBe('File not found: existing.json');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('existing.json'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns invalid/corrupt checkpoint message when schema parse fails', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"invalid": "data"}');
|
||||
vi.mocked(getToolCallDataSchema).mockReturnValue({
|
||||
safeParse: vi.fn().mockReturnValue({ success: false }),
|
||||
} as unknown as ReturnType<typeof getToolCallDataSchema>);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['invalid']);
|
||||
|
||||
expect(response.data).toBe('Checkpoint file is invalid or corrupted.');
|
||||
});
|
||||
|
||||
it('formats streamed restore results correctly', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"valid": "data"}');
|
||||
vi.mocked(getToolCallDataSchema).mockReturnValue({
|
||||
safeParse: vi
|
||||
.fn()
|
||||
.mockReturnValue({ success: true, data: { some: 'data' } }),
|
||||
} as unknown as ReturnType<typeof getToolCallDataSchema>);
|
||||
|
||||
async function* mockRestoreGenerator() {
|
||||
yield { type: 'message', messageType: 'info', content: 'Restoring...' };
|
||||
yield { type: 'load_history', clientHistory: [{}, {}] };
|
||||
yield { type: 'other', some: 'other' };
|
||||
}
|
||||
vi.mocked(performRestore).mockReturnValue(
|
||||
mockRestoreGenerator() as unknown as ReturnType<typeof performRestore>,
|
||||
);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['valid']);
|
||||
|
||||
expect(response.data).toContain('[INFO] Restoring...');
|
||||
expect(response.data).toContain('Loaded history with 2 messages.');
|
||||
expect(response.data).toContain(
|
||||
'Restored: {"type":"other","some":"other"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns generic unexpected error message for non-ENOENT failures', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Random error'));
|
||||
vi.mocked(isNodeError).mockReturnValue(false);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['error']);
|
||||
|
||||
expect(response.data).toContain(
|
||||
'An unexpected error occurred during restore: Error: Random error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListCheckpointsCommand', () => {
|
||||
let context: CommandContext;
|
||||
let listCommand: ListCheckpointsCommand;
|
||||
let mockReaddir: Mock<(path: string) => Promise<string[]>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
listCommand = new ListCheckpointsCommand();
|
||||
mockReaddir = vi.mocked(fs.readdir) as unknown as Mock<
|
||||
(path: string) => Promise<string[]>
|
||||
>;
|
||||
|
||||
context = {
|
||||
agentContext: {
|
||||
config: {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/checkpoints'),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('returns checkpointing-disabled message when disabled', async () => {
|
||||
(
|
||||
context.agentContext.config.getCheckpointingEnabled as Mock
|
||||
).mockReturnValue(false);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toContain('Checkpointing is not enabled');
|
||||
});
|
||||
|
||||
it('returns "No checkpoints found." when no .json checkpoints exist', async () => {
|
||||
mockReaddir.mockResolvedValue(['not-a-checkpoint.txt']);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('No checkpoints found.');
|
||||
});
|
||||
|
||||
it('ignores error when mkdir fails', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail'));
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('No checkpoints found.');
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('formats checkpoint summary output from checkpoint metadata', async () => {
|
||||
mockReaddir.mockResolvedValue(['cp1.json', 'cp2.json']);
|
||||
vi.mocked(getCheckpointInfoList).mockReturnValue([
|
||||
{ messageId: 'id1', checkpoint: 'cp1' },
|
||||
{ messageId: 'id2', checkpoint: 'cp2' },
|
||||
]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toContain('Available Checkpoints:');
|
||||
// Note: The current implementation of ListCheckpointsCommand incorrectly accesses
|
||||
// fileName, toolName, etc. which don't exist on CheckpointInfo, resulting in 'Unknown'.
|
||||
expect(response.data).toContain('- **Unknown**: Unknown (Status: Unknown)');
|
||||
});
|
||||
|
||||
it('handles empty checkpoint info list', async () => {
|
||||
mockReaddir.mockResolvedValue(['some.json']);
|
||||
vi.mocked(getCheckpointInfoList).mockReturnValue([]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('Available Checkpoints:\n');
|
||||
});
|
||||
|
||||
it('returns generic unexpected error message on failures', async () => {
|
||||
mockReaddir.mockRejectedValue(new Error('Readdir fail'));
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe(
|
||||
'An unexpected error occurred while listing checkpoints.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -20,8 +20,11 @@ import {
|
||||
getScopedEnvContents,
|
||||
type ExtensionSetting,
|
||||
} from '../../config/extensions/extensionSettings.js';
|
||||
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import prompts from 'prompts';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
|
||||
vi.hoisted(() => {
|
||||
@@ -84,7 +87,9 @@ describe('extensions configure command', () => {
|
||||
vi.spyOn(debugLogger, 'error');
|
||||
vi.clearAllMocks();
|
||||
|
||||
tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
|
||||
// Default behaviors
|
||||
mockLoadSettings.mockReturnValue({ merged: {} });
|
||||
@@ -94,7 +99,8 @@ describe('extensions configure command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir(tempWorkspaceDir);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -217,6 +217,78 @@ describe('mcp list command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display connected status even if ping fails', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockRejectedValue(new Error('Ping failed'));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-server: /test/server (stdio) - Connected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use configured timeout for connection', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server', timeout: 12345 },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 12345 }),
|
||||
);
|
||||
expect(mockClient.ping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 12345 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default timeout for connection when not configured', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
);
|
||||
expect(mockClient.ping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge extension servers with config servers', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
|
||||
@@ -67,6 +67,8 @@ export async function getMcpServersFromConfig(
|
||||
return filteredResult;
|
||||
}
|
||||
|
||||
const MCP_LIST_DEFAULT_TIMEOUT_MSEC = 5000;
|
||||
|
||||
async function testMCPConnection(
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
@@ -127,11 +129,22 @@ async function testMCPConnection(
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt actual MCP connection with short timeout
|
||||
await client.connect(transport, { timeout: 5000 }); // 5s timeout
|
||||
// Attempt actual MCP connection with timeout from config or default to 5s.
|
||||
// We use a short default for the list command to keep it responsive.
|
||||
const timeout = config.timeout ?? MCP_LIST_DEFAULT_TIMEOUT_MSEC;
|
||||
await client.connect(transport, { timeout });
|
||||
|
||||
// Test basic MCP protocol by pinging the server
|
||||
await client.ping();
|
||||
// Test basic MCP protocol by pinging the server.
|
||||
// Ping is optional per MCP spec - some servers (e.g. Google first-party)
|
||||
// don't implement it. A successful connect() is sufficient proof of connectivity.
|
||||
try {
|
||||
await client.ping({ timeout });
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`MCP ping failed for ${serverName}, but connect succeeded:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
return MCPServerStatus.CONNECTED;
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
type MCPServerConfig,
|
||||
type GeminiCLIExtension,
|
||||
Storage,
|
||||
generalistProfile,
|
||||
type ContextManagementConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import {
|
||||
@@ -233,6 +231,45 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('should fail if both --resume and --session-id are provided', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--resume',
|
||||
'--session-id',
|
||||
'test-uuid-1234',
|
||||
];
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
await expect(parseArguments(createTestMergedSettings())).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --resume (-r) and --session-id together',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse --session-id option correctly', async () => {
|
||||
process.argv = ['node', 'script.js', '--session-id', 'test-uuid-1234'];
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const parsedArgs = await parseArguments(createTestMergedSettings());
|
||||
expect(parsedArgs.sessionId).toBe('test-uuid-1234');
|
||||
});
|
||||
|
||||
describe('worktree', () => {
|
||||
it('should parse --worktree flag when provided with a name', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
|
||||
@@ -257,7 +294,7 @@ describe('parseArguments', () => {
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = false;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
const mockConsoleError = vi
|
||||
@@ -272,9 +309,6 @@ describe('parseArguments', () => {
|
||||
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -306,7 +340,7 @@ describe('parseArguments', () => {
|
||||
async ({ argv }) => {
|
||||
process.argv = argv;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -323,9 +357,6 @@ describe('parseArguments', () => {
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -562,7 +593,7 @@ describe('parseArguments', () => {
|
||||
async ({ argv }) => {
|
||||
process.argv = argv;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -579,9 +610,6 @@ describe('parseArguments', () => {
|
||||
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -606,7 +634,7 @@ describe('parseArguments', () => {
|
||||
it('should reject invalid --approval-mode values', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -625,10 +653,6 @@ describe('parseArguments', () => {
|
||||
expect.stringContaining('Invalid values:'),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => {
|
||||
@@ -780,6 +804,100 @@ describe('loadCliConfig', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Model resolution', () => {
|
||||
it('should handle multiple --model flags by taking the last one', async () => {
|
||||
const argv = {
|
||||
query: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: ['gemini-1.5-pro', 'gemini-2.0-flash'] as any,
|
||||
sandbox: undefined,
|
||||
debug: false,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: false,
|
||||
listSessions: false,
|
||||
deleteSession: undefined,
|
||||
screenReader: undefined,
|
||||
isCommand: false,
|
||||
rawOutput: false,
|
||||
acceptRawOutputRisk: false,
|
||||
startupMessages: [],
|
||||
resume: undefined,
|
||||
includeDirectories: [],
|
||||
useWriteTodos: false,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
skipTrust: false,
|
||||
};
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
'test-session',
|
||||
argv as unknown as CliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-2.0-flash');
|
||||
});
|
||||
|
||||
it('should handle non-string model flags by coercing to string', async () => {
|
||||
const argv = {
|
||||
query: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: true as any,
|
||||
sandbox: undefined,
|
||||
debug: false,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: false,
|
||||
listSessions: false,
|
||||
deleteSession: undefined,
|
||||
screenReader: undefined,
|
||||
isCommand: false,
|
||||
rawOutput: false,
|
||||
acceptRawOutputRisk: false,
|
||||
startupMessages: [],
|
||||
resume: undefined,
|
||||
includeDirectories: [],
|
||||
useWriteTodos: false,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
skipTrust: false,
|
||||
};
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
'test-session',
|
||||
argv as unknown as CliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxy configuration', () => {
|
||||
const originalProxyEnv: { [key: string]: string | undefined } = {};
|
||||
const proxyEnvVars = [
|
||||
@@ -872,16 +990,14 @@ describe('loadCliConfig', () => {
|
||||
});
|
||||
|
||||
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
|
||||
const resolveToRealPathSpy = vi
|
||||
.spyOn(ServerConfig, 'resolveToRealPath')
|
||||
.mockImplementation((p) => {
|
||||
if (p.toString().includes('restricted')) {
|
||||
const err = new Error('EACCES: permission denied');
|
||||
(err as NodeJS.ErrnoException).code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
vi.spyOn(ServerConfig, 'resolveToRealPath').mockImplementation((p) => {
|
||||
if (p.toString().includes('restricted')) {
|
||||
const err = new Error('EACCES: permission denied');
|
||||
(err as NodeJS.ErrnoException).code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
vi.stubEnv(
|
||||
'GEMINI_CLI_IDE_WORKSPACE_PATH',
|
||||
['/project/folderA', '/nonexistent/restricted/folder'].join(
|
||||
@@ -895,8 +1011,6 @@ describe('loadCliConfig', () => {
|
||||
const dirs = config.getPendingIncludeDirectories();
|
||||
expect(dirs).toContain('/project/folderA');
|
||||
expect(dirs).not.toContain('/nonexistent/restricted/folder');
|
||||
|
||||
resolveToRealPathSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should use default fileFilter options when unconfigured', async () => {
|
||||
@@ -1060,6 +1174,20 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
['.git'], // boundaryMarkers
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT call loadServerHierarchicalMemory when skipMemoryLoad is true', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: { jitContext: false },
|
||||
});
|
||||
|
||||
const argv = await parseArguments(settings);
|
||||
await loadCliConfig(settings, 'session-id', argv, {
|
||||
skipMemoryLoad: true,
|
||||
});
|
||||
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeMcpServers', () => {
|
||||
@@ -2217,51 +2345,6 @@ describe('loadCliConfig context management', () => {
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getContextManagementConfig()).toStrictEqual(
|
||||
generalistProfile,
|
||||
);
|
||||
expect(config.isContextManagementEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be true when contextManagement is set to true in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const contextManagementConfig: Partial<ContextManagementConfig> = {
|
||||
historyWindow: {
|
||||
maxTokens: 100_000,
|
||||
retainedTokens: 50_000,
|
||||
},
|
||||
messageLimits: {
|
||||
normalMaxTokens: 1000,
|
||||
retainedMaxTokens: 10_000,
|
||||
normalizationHeadRatio: 0.25,
|
||||
},
|
||||
tools: {
|
||||
distillation: {
|
||||
maxOutputTokens: 10_000,
|
||||
summarizationThresholdTokens: 15_000,
|
||||
},
|
||||
outputMasking: {
|
||||
protectionThresholdTokens: 30_000,
|
||||
minPrunableThresholdTokens: 10_000,
|
||||
protectLatestTurn: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
contextManagement: true,
|
||||
},
|
||||
// The type of numbers is being inferred strangely, and so we have to cast
|
||||
// to `any` here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
contextManagement: contextManagementConfig as any,
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getContextManagementConfig()).toStrictEqual({
|
||||
enabled: true,
|
||||
...contextManagementConfig,
|
||||
});
|
||||
expect(config.isContextManagementEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3225,7 +3308,7 @@ describe('Output format', () => {
|
||||
it('should error on invalid --output-format argument', async () => {
|
||||
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -3243,10 +3326,6 @@ describe('Output format', () => {
|
||||
expect.stringContaining('Invalid values:'),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3277,13 +3356,11 @@ describe('parseArguments with positional prompt', () => {
|
||||
'test prompt',
|
||||
];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -3297,10 +3374,6 @@ describe('parseArguments with positional prompt', () => {
|
||||
'Cannot use both a positional prompt and the --prompt (-p) flag together',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should correctly parse a positional prompt to query field', async () => {
|
||||
@@ -3929,7 +4002,7 @@ describe('loadCliConfig acpMode and clientName', () => {
|
||||
expect(config.getClientName()).toBe('acp-vscode');
|
||||
});
|
||||
|
||||
it('should set acpMode to true but leave clientName undefined for generic terminals', async () => {
|
||||
it('should set acpMode to true and set clientName to acp for generic terminals', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp'];
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal
|
||||
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
|
||||
@@ -3941,10 +4014,10 @@ describe('loadCliConfig acpMode and clientName', () => {
|
||||
argv,
|
||||
);
|
||||
expect(config.getAcpMode()).toBe(true);
|
||||
expect(config.getClientName()).toBeUndefined();
|
||||
expect(config.getClientName()).toBe('acp');
|
||||
});
|
||||
|
||||
it('should set acpMode to false and clientName to undefined by default', async () => {
|
||||
it('should set acpMode to false and clientName to tui by default', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
@@ -3953,6 +4026,6 @@ describe('loadCliConfig acpMode and clientName', () => {
|
||||
argv,
|
||||
);
|
||||
expect(config.getAcpMode()).toBe(false);
|
||||
expect(config.getClientName()).toBeUndefined();
|
||||
expect(config.getClientName()).toBe('tui');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
getAdminErrorMessage,
|
||||
isHeadlessMode,
|
||||
Config,
|
||||
SimpleExtensionLoader,
|
||||
resolveToRealPath,
|
||||
applyAdminAllowlist,
|
||||
applyRequiredServers,
|
||||
@@ -48,7 +49,6 @@ import {
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
detectIdeFromEnv,
|
||||
generalistProfile,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -97,6 +97,7 @@ export interface CliArgs {
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
resume: string | typeof RESUME_LATEST | undefined;
|
||||
sessionId: string | undefined;
|
||||
listSessions: boolean | undefined;
|
||||
deleteSession: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
@@ -238,6 +239,10 @@ export async function parseArguments(
|
||||
? query.length > 0
|
||||
: !!query;
|
||||
|
||||
if (argv['resume'] !== undefined && argv['session-id'] !== undefined) {
|
||||
return 'Cannot use both --resume (-r) and --session-id together';
|
||||
}
|
||||
|
||||
if (argv['prompt'] && hasPositionalQuery) {
|
||||
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
|
||||
}
|
||||
@@ -407,6 +412,25 @@ export async function parseArguments(
|
||||
return trimmed;
|
||||
},
|
||||
})
|
||||
.option('session-id', {
|
||||
type: 'string',
|
||||
nargs: 1,
|
||||
description: 'Start a new session with a manually provided UUID.',
|
||||
coerce: (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('The --session-id option cannot be empty.');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
'Invalid session ID "' +
|
||||
trimmed +
|
||||
'": Only alphanumeric characters, dashes, and underscores are allowed.',
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
})
|
||||
.option('list-sessions', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
@@ -535,6 +559,8 @@ export interface LoadCliConfigOptions {
|
||||
disabled?: string[];
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
skipExtensions?: boolean;
|
||||
skipMemoryLoad?: boolean;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -543,7 +569,12 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
projectHooks,
|
||||
skipExtensions = false,
|
||||
skipMemoryLoad = false,
|
||||
} = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
@@ -618,21 +649,24 @@ export async function loadCliConfig(
|
||||
includeDirectories.push(...ideFolders);
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
let extensionManager: ExtensionManager | undefined;
|
||||
if (!skipExtensions) {
|
||||
extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
workspaceDir: cwd,
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
await extensionManager.loadExtensions();
|
||||
}
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
?.getExtensions()
|
||||
?.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext ?? true;
|
||||
|
||||
@@ -650,7 +684,10 @@ export async function loadCliConfig(
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
|
||||
if (!experimentalJitContext) {
|
||||
const finalExtensionLoader =
|
||||
extensionManager ?? new SimpleExtensionLoader([]);
|
||||
|
||||
if (!experimentalJitContext && !skipMemoryLoad) {
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
@@ -658,7 +695,7 @@ export async function loadCliConfig(
|
||||
? includeDirectories
|
||||
: [],
|
||||
fileService,
|
||||
extensionManager,
|
||||
finalExtensionLoader,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
@@ -818,9 +855,16 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const specifiedModel =
|
||||
const rawModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
|
||||
// Ensure specifiedModel is a string (e.g. if yargs parsed multiple --model as an array)
|
||||
const specifiedModel = Array.isArray(rawModel)
|
||||
? String(rawModel.at(-1) ?? '').trim() || ''
|
||||
: rawModel === undefined
|
||||
? undefined
|
||||
: String(rawModel ?? '').trim() || '';
|
||||
|
||||
const resolvedModel =
|
||||
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
|
||||
? defaultModel
|
||||
@@ -901,17 +945,28 @@ export async function loadCliConfig(
|
||||
(ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode')
|
||||
) {
|
||||
clientName = `acp-${ide.name}`;
|
||||
} else {
|
||||
clientName = 'acp';
|
||||
}
|
||||
} else if (argv.isCommand) {
|
||||
clientName = 'cli-command';
|
||||
} else {
|
||||
clientName = 'tui';
|
||||
}
|
||||
|
||||
// TODO(joshualitt): Clean this up alongside removal of the legacy config.
|
||||
let profileSelector: string | undefined = undefined;
|
||||
if (settings.experimental?.stressTestProfile) {
|
||||
profileSelector = 'stressTestProfile';
|
||||
} else if (
|
||||
settings.experimental?.generalistProfile ||
|
||||
settings.experimental?.contextManagement
|
||||
) {
|
||||
profileSelector = 'generalistProfile';
|
||||
}
|
||||
|
||||
const useGeneralistProfile =
|
||||
settings.experimental?.generalistProfile ?? false;
|
||||
const useContextManagement =
|
||||
settings.experimental?.contextManagement ?? false;
|
||||
const contextManagement = {
|
||||
...(useGeneralistProfile ? generalistProfile : {}),
|
||||
...(useContextManagement ? settings?.contextManagement : {}),
|
||||
enabled: useContextManagement || useGeneralistProfile,
|
||||
enabled: !!profileSelector,
|
||||
};
|
||||
|
||||
return new Config({
|
||||
@@ -935,6 +990,7 @@ export async function loadCliConfig(
|
||||
worktreeSettings,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
experimentalContextManagementConfig: profileSelector,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
policyEngineConfig,
|
||||
policyUpdateConfirmationRequest,
|
||||
@@ -995,11 +1051,12 @@ export async function loadCliConfig(
|
||||
listSessions: argv.listSessions || false,
|
||||
deleteSession: argv.deleteSession,
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
extensionLoader: finalExtensionLoader,
|
||||
extensionRegistryURI,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
voiceMode: settings.experimental?.voiceMode,
|
||||
tracker: settings.experimental?.taskTracker,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
|
||||
@@ -42,10 +42,12 @@ describe('ExtensionManager agents loading', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
|
||||
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-agents-'));
|
||||
mockHomedir.mockReturnValue(tempDir);
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
|
||||
// Create the extensions directory that ExtensionManager expects
|
||||
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
|
||||
|
||||
@@ -48,11 +48,13 @@ describe('ExtensionManager hydration', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
vi.spyOn(coreEvents, 'emitFeedback');
|
||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
mockHomedir.mockReturnValue(tempDir);
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
|
||||
// Create the extensions directory that ExtensionManager expects
|
||||
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
|
||||
import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
@@ -87,8 +88,9 @@ describe('ExtensionManager Settings Scope', () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up files if needed, or rely on temp dir cleanup
|
||||
afterEach(async () => {
|
||||
await cleanupTmpDir(currentTempHome);
|
||||
await cleanupTmpDir(tempWorkspace);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -149,6 +149,35 @@ describe('consent', () => {
|
||||
expect(consent).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should clear the active confirmation request before resolving', async () => {
|
||||
const clearConfirmationRequest = vi.fn();
|
||||
const steps: string[] = [];
|
||||
const addExtensionUpdateConfirmationRequest = vi
|
||||
.fn()
|
||||
.mockImplementation((request: ConfirmationRequest) => {
|
||||
steps.push('prompted');
|
||||
request.onConfirm(true);
|
||||
steps.push('confirmed');
|
||||
});
|
||||
|
||||
const consentPromise = requestConsentInteractive(
|
||||
'Test consent',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
() => {
|
||||
steps.push('cleared');
|
||||
clearConfirmationRequest();
|
||||
},
|
||||
).then((consent) => {
|
||||
steps.push('resolved');
|
||||
return consent;
|
||||
});
|
||||
|
||||
expect(clearConfirmationRequest).toHaveBeenCalledTimes(1);
|
||||
expect(steps).toEqual(['prompted', 'cleared', 'confirmed']);
|
||||
await expect(consentPromise).resolves.toBe(true);
|
||||
expect(steps).toEqual(['prompted', 'cleared', 'confirmed', 'resolved']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeRequestConsentOrFail', () => {
|
||||
|
||||
@@ -78,10 +78,12 @@ export async function requestConsentNonInteractive(
|
||||
export async function requestConsentInteractive(
|
||||
consentDescription: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
clearConfirmationRequest?: () => void,
|
||||
): Promise<boolean> {
|
||||
return promptForConsentInteractive(
|
||||
consentDescription + '\n\nDo you want to continue?',
|
||||
addExtensionUpdateConfirmationRequest,
|
||||
clearConfirmationRequest,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,12 +131,14 @@ export async function promptForConsentNonInteractive(
|
||||
async function promptForConsentInteractive(
|
||||
prompt: string,
|
||||
addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
|
||||
clearConfirmationRequest?: () => void,
|
||||
): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
addExtensionUpdateConfirmationRequest({
|
||||
prompt,
|
||||
onConfirm: (resolvedConfirmed) => {
|
||||
resolve(resolvedConfirmed);
|
||||
clearConfirmationRequest?.();
|
||||
setImmediate(() => resolve(resolvedConfirmed));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import type * as osActual from 'node:os';
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actualOs = await importOriginal<typeof osActual>();
|
||||
return {
|
||||
...actualOs,
|
||||
homedir: vi.fn(() => path.resolve('/mock/home')),
|
||||
platform: vi.fn(() => 'linux'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: vi.fn(() => path.resolve('/mock/home')),
|
||||
};
|
||||
});
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import { loadEnvironment, type Settings } from './settings.js';
|
||||
import { GEMINI_DIR, homedir as coreHomedir } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('node:fs');
|
||||
|
||||
describe('Environment Isolation', () => {
|
||||
const mockHome = path.resolve('/mock/home');
|
||||
const mockWorkspace = path.resolve('/mock/workspace');
|
||||
const originalArgv = process.argv;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHome);
|
||||
vi.mocked(coreHomedir).mockReturnValue(mockHome);
|
||||
// Default to no files existing
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
process.argv = ['node', 'gemini'];
|
||||
|
||||
// Clear env vars that might leak from the host environment
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
delete process.env['OTHER_VAR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('should load local .env by default', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('local');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should ignore local .env when ignoreLocalEnv is true', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
return ps === workspaceEnv || ps === homeEnv;
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
if (ps === workspaceEnv) return 'GEMINI_API_KEY=local';
|
||||
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
|
||||
return '';
|
||||
});
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
// Should skip local and find home
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should still load .gemini/.env even if ignoreLocalEnv is true', () => {
|
||||
const workspaceGeminiEnv = path.join(mockWorkspace, GEMINI_DIR, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceGeminiEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=gemini-local');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('gemini-local');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should respect --ignore-env flag', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow home .env even with ignoreLocalEnv true', () => {
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === homeEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=home');
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
// Running from home dir
|
||||
loadEnvironment(settings, mockHome, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should skip local .env and its parents until home when ignoreLocalEnv is true', () => {
|
||||
const deepProject = path.join(mockWorkspace, 'deep', 'dir');
|
||||
const deepEnv = path.join(deepProject, '.env');
|
||||
const parentEnv = path.join(mockWorkspace, '.env');
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
return ps === deepEnv || ps === parentEnv || ps === homeEnv;
|
||||
});
|
||||
vi.mocked(fs.readFileSync).mockImplementation((p) => {
|
||||
const ps = p.toString();
|
||||
if (ps === deepEnv) return 'GEMINI_API_KEY=deep';
|
||||
if (ps === parentEnv) return 'GEMINI_API_KEY=parent';
|
||||
if (ps === homeEnv) return 'GEMINI_API_KEY=home';
|
||||
return '';
|
||||
});
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
loadEnvironment(settings, deepProject, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should respect trust whitelist even when loading from home .env', () => {
|
||||
const homeEnv = path.join(mockHome, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === homeEnv,
|
||||
);
|
||||
// Include one whitelisted and one non-whitelisted variable
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'GEMINI_API_KEY=home\nOTHER_VAR=secret',
|
||||
);
|
||||
|
||||
const settings = { advanced: { ignoreLocalEnv: true } } as Settings;
|
||||
// Running from an UNTRUSTED workspace
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBe('home');
|
||||
expect(process.env['OTHER_VAR']).toBeUndefined();
|
||||
delete process.env['GEMINI_API_KEY'];
|
||||
});
|
||||
|
||||
it('should prioritize --ignore-env flag even if setting is false', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should respect both -s and --ignore-env flags simultaneously', () => {
|
||||
const workspaceEnv = path.join(mockWorkspace, '.env');
|
||||
vi.mocked(fs.existsSync).mockImplementation(
|
||||
(p) => p.toString() === workspaceEnv,
|
||||
);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('GEMINI_API_KEY=local');
|
||||
|
||||
process.argv = ['node', 'gemini', '-s', '--ignore-env'];
|
||||
const settings = { advanced: { ignoreLocalEnv: false } } as Settings;
|
||||
loadEnvironment(settings, mockWorkspace, () => ({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
}));
|
||||
|
||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user