mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf86f34576 | |||
| 518832c586 | |||
| 60b79d676a | |||
| 1d72a120fb | |||
| 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 | |||
| 40b384de2c | |||
| de8fdcfa16 | |||
| 408afd3c5a | |||
| a93d2a1d1c | |||
| dc5b3114c0 | |||
| 9380e13f6d | |||
| 363854172f | |||
| 7dea5b47a1 | |||
| 997f461cad | |||
| b14a29efa2 | |||
| f496354884 | |||
| 76d1a73606 | |||
| 8fb1b5aa01 | |||
| 9cb48020e1 | |||
| 8943640a71 | |||
| 7213822e84 | |||
| d9f273e440 | |||
| 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 |
@@ -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,6 @@
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"gemma": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": 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 = 60;
|
||||
const CLOSE_DAYS = 14;
|
||||
const NO_RESPONSE_DAYS = 14;
|
||||
|
||||
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
|
||||
@@ -41,7 +41,8 @@ jobs:
|
||||
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' && contains(github.event.comment.body, '@gemini-cli-robot') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association))
|
||||
(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:
|
||||
@@ -52,9 +53,25 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -85,8 +102,15 @@ jobs:
|
||||
if [ -n "$LAST_RUN_ID" ]; then
|
||||
echo "Found previous successful run: $LAST_RUN_ID"
|
||||
|
||||
# Download brain memory (all state in one artifact)
|
||||
gh run download "$LAST_RUN_ID" -n brain-data -D . || echo "brain-data not found"
|
||||
# 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
|
||||
@@ -119,7 +143,7 @@ jobs:
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -132,8 +156,15 @@ jobs:
|
||||
|
||||
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
|
||||
|
||||
- name: 'Run Critique Phase'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
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:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
@@ -154,7 +185,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 'Generate Patch'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
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
|
||||
@@ -190,10 +221,38 @@ jobs:
|
||||
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="main"
|
||||
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: 'main'
|
||||
ref: '${{ steps.determine_ref.outputs.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -204,13 +263,14 @@ jobs:
|
||||
path: '${{ runner.temp }}/brain-data/'
|
||||
|
||||
- name: 'Create or Update PR'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
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: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
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-robot"
|
||||
git config user.email "gemini-cli-robot@google.com"
|
||||
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 }}"
|
||||
@@ -223,7 +283,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git checkout -B "$BRANCH_NAME"
|
||||
git apply "${{ runner.temp }}/brain-data/bot-changes.patch"
|
||||
git add .
|
||||
|
||||
@@ -233,36 +293,51 @@ jobs:
|
||||
git commit -m "🤖 Gemini Bot Productivity Optimizations"
|
||||
fi
|
||||
|
||||
git push origin "$BRANCH_NAME" --force
|
||||
|
||||
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: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
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"
|
||||
gh issue comment "$TRIGGER_ISSUE_NUMBER" -F "${{ runner.temp }}/brain-data/issue-comment.md"
|
||||
# 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")
|
||||
PR_AUTHOR=$(gh pr view "$PR_NUM" --json author --jq '.author.login')
|
||||
if [ "$PR_AUTHOR" != "gemini-cli-robot" ]; then
|
||||
echo "Error: PR #$PR_NUM is authored by '$PR_AUTHOR', not 'gemini-cli-robot'. Safety abort."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh pr comment "$PR_NUM" -F "${{ runner.temp }}/brain-data/pr-comment.md"
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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'
|
||||
+42
-1
@@ -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"
|
||||
@@ -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,24 @@ 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
|
||||
|
||||
+167
-242
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.39.0
|
||||
# Latest stable release: v0.40.0
|
||||
|
||||
Released: April 23, 2026
|
||||
Released: April 28, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,252 +11,177 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Skill Extractor & Memory Inbox:** Introduced the `/memory` command to review
|
||||
and patch skills extracted during agent sessions, streamlining the continuous
|
||||
learning workflow.
|
||||
- **Enhanced Plan Mode Security:** Increased transparency in Plan Mode by
|
||||
requiring user confirmation for skill activation and allowing users to view
|
||||
the full content of generated plans.
|
||||
- **Advanced Display Protocol:** Implemented a tool-controlled display protocol,
|
||||
enabling agents to provide richer, more structured visual feedback during
|
||||
execution.
|
||||
- **Core Architecture Refactor:** Introduced a decoupled `ContextManager` and
|
||||
`Sidecar` architecture to improve state management and session resilience.
|
||||
- **Streamlined Agent Feedback:** Restored the display of model thoughts and raw
|
||||
text in responses, ensuring full visibility into the agent's reasoning
|
||||
process.
|
||||
- **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
|
||||
|
||||
- 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.40.0-nightly.20260414.g5b1f7375a 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
|
||||
@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
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
[#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)
|
||||
- fix(patch): cherry-pick a4e98c0 to release/v0.39.0-preview.0-pr-25138 to patch
|
||||
version v0.39.0-preview.0 and create version 0.39.0-preview.1 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
|
||||
[#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
|
||||
[#25766](https://github.com/google-gemini/gemini-cli/pull/25766)
|
||||
- fix(patch): cherry-pick d6f88f8 to release/v0.39.0-preview.1-pr-25670 to patch
|
||||
version v0.39.0-preview.1 and create version 0.39.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#25776](https://github.com/google-gemini/gemini-cli/pull/25776)
|
||||
[#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.2...v0.39.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.39.1...v0.40.0
|
||||
|
||||
+99
-385
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.40.0-preview.3
|
||||
# Preview release: v0.41.0-preview.0
|
||||
|
||||
Released: April 24, 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,395 +13,109 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Ripgrep Binary Bundling:** Ripgrep binaries are now bundled into the Single
|
||||
Executable Application (SEA), enabling grep functionality in offline
|
||||
environments.
|
||||
- **MCP Resource Tools:** New core tools added to list and read MCP (Model
|
||||
Context Protocol) resources, expanding the agent's ability to interact with
|
||||
MCP servers.
|
||||
- **Local Model Setup:** Introduced a streamlined `gemini gemma` command for
|
||||
easier local model setup and integration.
|
||||
- **Prompt-Driven Memory Management:** Refactored memory management into a
|
||||
prompt-driven, four-tier system and integrated `skill-creator` for robust
|
||||
skill extraction.
|
||||
- **Enhanced UI and Accessibility:** Added support for OSC 777 terminal
|
||||
notifications and GitHub colorblind themes for better user feedback and
|
||||
accessibility.
|
||||
- **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
|
||||
|
||||
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
|
||||
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
|
||||
- feat(core): enhance shell command validation and add core tools allowlist by
|
||||
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
|
||||
- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by
|
||||
@gemini-cli-robot in
|
||||
[#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)
|
||||
- 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
|
||||
- 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
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
[#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
|
||||
[#25537](https://github.com/google-gemini/gemini-cli/pull/25537)
|
||||
- fix(cli): ensure theme dialog labels are rendered for all themes by
|
||||
@JayadityaGit in
|
||||
[#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)
|
||||
- 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
|
||||
@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
|
||||
@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
|
||||
@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)
|
||||
[#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.40.0-preview.3
|
||||
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
|
||||
|
||||
|
||||
@@ -158,15 +158,16 @@ 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` |
|
||||
| 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. | `"gemini-live"` |
|
||||
| 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` |
|
||||
@@ -177,7 +178,7 @@ they appear in the UI.
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -504,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 (`@`)
|
||||
|
||||
|
||||
@@ -1191,7 +1191,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1199,18 +1199,54 @@ 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",
|
||||
"not_found": "prompt",
|
||||
"unknown": "prompt"
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default": [
|
||||
@@ -1232,18 +1268,54 @@ 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",
|
||||
"not_found": "prompt",
|
||||
"unknown": "prompt"
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
}
|
||||
],
|
||||
"lite": [
|
||||
@@ -1257,7 +1329,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1272,7 +1344,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1288,7 +1360,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -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,8 +1765,8 @@ 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):
|
||||
@@ -1702,7 +1780,9 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Values:** `"push-to-talk"`, `"toggle"`
|
||||
|
||||
- **`experimental.voice.backend`** (enum):
|
||||
- **Description:** The backend to use for voice transcription.
|
||||
- **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"`
|
||||
|
||||
@@ -1853,8 +1933,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **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
|
||||
|
||||
@@ -2508,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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,4 +179,24 @@ describe('grep_search_functionality', () => {
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should find files in hidden directories',
|
||||
files: {
|
||||
'.hidden_dir/system.md':
|
||||
'Instructional Conversion and Integration Protocol',
|
||||
'visible_dir/normal.txt': 'nothing to see here',
|
||||
},
|
||||
prompt: 'Find "Instructional Conversion and Integration Protocol"',
|
||||
assert: async (rig: TestRig, result: string) => {
|
||||
await rig.waitForToolCall('grep_search');
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/\\.hidden_dir[\\\\/]system\\.md/],
|
||||
testName: `${TEST_PREFIX}hidden directory search`,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
@@ -106,6 +107,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Simulate A2A client confirmation
|
||||
@@ -148,7 +150,11 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Simulate Rejection (Cancel)
|
||||
const handled = await (
|
||||
@@ -174,7 +180,11 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
correlationId: 'corr-2',
|
||||
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
|
||||
};
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] });
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall2],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Simulate ModifyWithEditor
|
||||
const handled2 = await (
|
||||
@@ -215,7 +225,11 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Simulate ProceedOnce for MCP
|
||||
const handled = await (
|
||||
@@ -255,7 +269,11 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
const handled = await (
|
||||
task as unknown as {
|
||||
@@ -294,7 +312,11 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
const handled = await (
|
||||
task as unknown as {
|
||||
@@ -333,7 +355,11 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
const handled = await (
|
||||
task as unknown as {
|
||||
@@ -376,7 +402,11 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
|
||||
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
|
||||
@@ -419,6 +449,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Should publish artifact update for output
|
||||
@@ -453,7 +484,11 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
const handler = (messageBus.subscribe as Mock).mock.calls.find(
|
||||
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
|
||||
)?.[1];
|
||||
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// The tool should be complete and registered appropriately, eventually
|
||||
// triggering the toolCompletionPromise resolution when all clear.
|
||||
@@ -533,6 +568,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall1, toolCall2],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Confirm first tool call
|
||||
@@ -600,6 +636,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall1, toolCall2],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Should NOT transition to input-required yet
|
||||
@@ -621,6 +658,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
handler({
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [toolCall1Complete, toolCall2],
|
||||
schedulerId: 'task-id',
|
||||
});
|
||||
|
||||
// Now it should transition
|
||||
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
type ToolCallRequestInfo,
|
||||
type GitService,
|
||||
type CompletedToolCall,
|
||||
type ToolCall,
|
||||
type ToolCallsUpdateMessage,
|
||||
MessageBusType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockConfig } from '../utils/testing_utils.js';
|
||||
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
|
||||
@@ -460,4 +463,204 @@ describe('Task', () => {
|
||||
expect(task.currentPromptId).toBe(expectedPromptId2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Race Condition Fix', () => {
|
||||
const mockConfig = createMockConfig();
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should NOT transition to input-required if a tool is still validating', async () => {
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
// Manually register two tool calls
|
||||
task['_registerToolCall']('tool-1', 'awaiting_approval');
|
||||
task['_registerToolCall']('tool-2', 'validating');
|
||||
|
||||
// Call checkInputRequiredState (private)
|
||||
task['checkInputRequiredState']();
|
||||
|
||||
// Verify task state did NOT change to input-required
|
||||
expect(task.taskState).not.toBe('input-required');
|
||||
expect(mockEventBus.publish).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: expect.objectContaining({ state: 'input-required' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should transition to input-required if all active tools are awaiting approval', async () => {
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
// Transition from submitted to working first to simulate normal flow
|
||||
task.taskState = 'working';
|
||||
|
||||
// Manually register tool calls
|
||||
task['_registerToolCall']('tool-1', 'awaiting_approval');
|
||||
|
||||
// Call checkInputRequiredState
|
||||
task['checkInputRequiredState']();
|
||||
|
||||
// Verify task state changed to input-required
|
||||
expect(task.taskState).toBe('input-required');
|
||||
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: expect.objectContaining({ state: 'input-required' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => {
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const handleEventDrivenToolCallSpy = vi.spyOn(
|
||||
task as unknown as {
|
||||
handleEventDrivenToolCall: Task['handleEventDrivenToolCall'];
|
||||
},
|
||||
'handleEventDrivenToolCall',
|
||||
);
|
||||
|
||||
const otherEvent: ToolCallsUpdateMessage = {
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [
|
||||
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
|
||||
],
|
||||
schedulerId: 'other-task-id',
|
||||
};
|
||||
|
||||
task['handleEventDrivenToolCallsUpdate'](otherEvent);
|
||||
|
||||
expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled();
|
||||
|
||||
const ownEvent: ToolCallsUpdateMessage = {
|
||||
type: MessageBusType.TOOL_CALLS_UPDATE,
|
||||
toolCalls: [
|
||||
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
|
||||
],
|
||||
schedulerId: 'task-id',
|
||||
};
|
||||
|
||||
task['handleEventDrivenToolCallsUpdate'](ownEvent);
|
||||
|
||||
expect(handleEventDrivenToolCallSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serialization and Mapping', () => {
|
||||
it('should map internal "validating" status to "scheduled" for the client and include outcome', async () => {
|
||||
const mockConfig = createMockConfig();
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const mockToolCall = {
|
||||
request: { callId: 'tool-1' },
|
||||
status: 'validating',
|
||||
outcome: 'accepted',
|
||||
tool: { name: 'test-tool' },
|
||||
};
|
||||
|
||||
const message = task['toolStatusMessage'](
|
||||
mockToolCall as unknown as ToolCall,
|
||||
'task-id',
|
||||
'context-id',
|
||||
);
|
||||
const serialized = (
|
||||
message.parts![0] as {
|
||||
data: { status: string; outcome: string };
|
||||
}
|
||||
).data;
|
||||
|
||||
expect(serialized.status).toBe('scheduled');
|
||||
expect(serialized.outcome).toBe('accepted');
|
||||
});
|
||||
|
||||
it('should correctly detect changes when status or outcome changes', async () => {
|
||||
const mockConfig = createMockConfig();
|
||||
const mockEventBus: ExecutionEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
finished: vi.fn(),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task(
|
||||
'task-id',
|
||||
'context-id',
|
||||
mockConfig as Config,
|
||||
mockEventBus,
|
||||
);
|
||||
|
||||
const toolCall1 = {
|
||||
request: { callId: 'tool-1' },
|
||||
status: 'awaiting_approval',
|
||||
};
|
||||
|
||||
// First update - should trigger change
|
||||
const changed1 = task['handleEventDrivenToolCall'](
|
||||
toolCall1 as unknown as ToolCall,
|
||||
);
|
||||
expect(changed1).toBe(true);
|
||||
|
||||
// Second update with same status - should NOT trigger change
|
||||
const changed2 = task['handleEventDrivenToolCall'](
|
||||
toolCall1 as unknown as ToolCall,
|
||||
);
|
||||
expect(changed2).toBe(false);
|
||||
|
||||
// Update with new outcome - SHOULD trigger change
|
||||
const toolCall2 = {
|
||||
request: { callId: 'tool-1' },
|
||||
status: 'awaiting_approval',
|
||||
outcome: 'accepted',
|
||||
};
|
||||
const changed3 = task['handleEventDrivenToolCall'](
|
||||
toolCall2 as unknown as ToolCall,
|
||||
);
|
||||
expect(changed3).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GeminiEventType,
|
||||
ToolConfirmationOutcome,
|
||||
ApprovalMode,
|
||||
CoreToolCallStatus,
|
||||
getAllMCPServerStatuses,
|
||||
MCPServerStatus,
|
||||
isNodeError,
|
||||
@@ -95,6 +96,8 @@ export class Task {
|
||||
|
||||
// For tool waiting logic
|
||||
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
|
||||
private pendingOutcomes: Map<string, ToolConfirmationOutcome | undefined> =
|
||||
new Map(); // toolCallId --> outcome
|
||||
private toolsAlreadyConfirmed: Set<string> = new Set();
|
||||
private toolCompletionPromise?: Promise<void>;
|
||||
private toolCompletionNotifier?: {
|
||||
@@ -413,7 +416,10 @@ export class Task {
|
||||
private handleEventDrivenToolCallsUpdate(
|
||||
event: ToolCallsUpdateMessage,
|
||||
): void {
|
||||
if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) {
|
||||
if (
|
||||
event.type !== MessageBusType.TOOL_CALLS_UPDATE ||
|
||||
event.schedulerId !== this.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -426,7 +432,7 @@ export class Task {
|
||||
this.checkInputRequiredState();
|
||||
}
|
||||
|
||||
private handleEventDrivenToolCall(tc: ToolCall): void {
|
||||
private handleEventDrivenToolCall(tc: ToolCall): boolean {
|
||||
const callId = tc.request.callId;
|
||||
|
||||
// Do not process events for tools that have already been finalized.
|
||||
@@ -436,11 +442,16 @@ export class Task {
|
||||
this.processedToolCallIds.has(callId) ||
|
||||
this.completedToolCalls.some((c) => c.request.callId === callId)
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousStatus = this.pendingToolCalls.get(callId);
|
||||
const hasChanged = previousStatus !== tc.status;
|
||||
const previousOutcome = this.pendingOutcomes.get(callId);
|
||||
const hasChanged =
|
||||
previousStatus !== tc.status || previousOutcome !== tc.outcome;
|
||||
|
||||
// Update outcome tracking
|
||||
this.pendingOutcomes.set(callId, tc.outcome);
|
||||
|
||||
// 1. Handle Output
|
||||
if (tc.status === 'executing' && tc.liveOutput) {
|
||||
@@ -454,6 +465,7 @@ export class Task {
|
||||
tc.status === 'cancelled'
|
||||
) {
|
||||
this.toolsAlreadyConfirmed.delete(callId);
|
||||
this.pendingOutcomes.delete(callId);
|
||||
if (hasChanged) {
|
||||
logger.info(
|
||||
`[Task] Tool call ${callId} completed with status: ${tc.status}`,
|
||||
@@ -496,6 +508,8 @@ export class Task {
|
||||
);
|
||||
this.eventBus?.publish(statusUpdate);
|
||||
}
|
||||
|
||||
return hasChanged;
|
||||
}
|
||||
|
||||
private checkInputRequiredState(): void {
|
||||
@@ -508,12 +522,14 @@ export class Task {
|
||||
let isExecuting = false;
|
||||
|
||||
for (const [callId, status] of this.pendingToolCalls.entries()) {
|
||||
if (status === 'executing' || status === 'scheduled') {
|
||||
isExecuting = true;
|
||||
} else if (
|
||||
status === 'awaiting_approval' &&
|
||||
!this.toolsAlreadyConfirmed.has(callId)
|
||||
if (
|
||||
status === CoreToolCallStatus.Executing ||
|
||||
status === CoreToolCallStatus.Scheduled ||
|
||||
status === CoreToolCallStatus.Validating ||
|
||||
this.toolsAlreadyConfirmed.has(callId)
|
||||
) {
|
||||
isExecuting = true;
|
||||
} else if (status === CoreToolCallStatus.AwaitingApproval) {
|
||||
isAwaitingApproval = true;
|
||||
}
|
||||
}
|
||||
@@ -574,8 +590,14 @@ export class Task {
|
||||
'confirmationDetails',
|
||||
'liveOutput',
|
||||
'response',
|
||||
'outcome',
|
||||
);
|
||||
|
||||
// Map internal 'validating' status to 'scheduled' for the client
|
||||
if (serializableToolCall.status === CoreToolCallStatus.Validating) {
|
||||
serializableToolCall.status = CoreToolCallStatus.Scheduled;
|
||||
}
|
||||
|
||||
if (tc.tool) {
|
||||
const toolFields = this._pickFields(
|
||||
tc.tool,
|
||||
|
||||
@@ -228,7 +228,7 @@ describe('E2E Tests', () => {
|
||||
expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
status: 'validating',
|
||||
status: 'scheduled',
|
||||
request: { callId: 'test-call-id' },
|
||||
},
|
||||
},
|
||||
@@ -330,7 +330,7 @@ describe('E2E Tests', () => {
|
||||
expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
status: 'validating',
|
||||
status: 'scheduled',
|
||||
request: { callId: 'test-call-id-1' },
|
||||
},
|
||||
},
|
||||
@@ -352,7 +352,7 @@ describe('E2E Tests', () => {
|
||||
kind: 'state-change',
|
||||
});
|
||||
|
||||
// 4. Tool 1 is validating.
|
||||
// 4. Tool 1 is scheduled.
|
||||
const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent;
|
||||
expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
@@ -361,12 +361,12 @@ describe('E2E Tests', () => {
|
||||
{
|
||||
data: {
|
||||
request: { callId: 'test-call-id-1' },
|
||||
status: 'validating',
|
||||
status: 'scheduled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// 5. Tool 2 is validating.
|
||||
// 5. Tool 2 is scheduled.
|
||||
const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent;
|
||||
expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
@@ -375,17 +375,17 @@ describe('E2E Tests', () => {
|
||||
{
|
||||
data: {
|
||||
request: { callId: 'test-call-id-2' },
|
||||
status: 'validating',
|
||||
status: 'scheduled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// 6. Tool 1 is awaiting approval.
|
||||
const toolCallAwaitEvent = events[5].result as TaskStatusUpdateEvent;
|
||||
expect(toolCallAwaitEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent;
|
||||
expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-confirmation',
|
||||
});
|
||||
expect(toolCallAwaitEvent.status.message?.parts).toMatchObject([
|
||||
expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
request: { callId: 'test-call-id-1' },
|
||||
@@ -394,14 +394,28 @@ describe('E2E Tests', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// 7. The final event is "input-required".
|
||||
const finalEvent = events[6].result as TaskStatusUpdateEvent;
|
||||
// 7. Tool 2 is awaiting approval.
|
||||
const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent;
|
||||
expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-confirmation',
|
||||
});
|
||||
expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
request: { callId: 'test-call-id-2' },
|
||||
status: 'awaiting_approval',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// 8. The final event is "input-required".
|
||||
const finalEvent = events[7].result as TaskStatusUpdateEvent;
|
||||
expect(finalEvent.final).toBe(true);
|
||||
expect(finalEvent.status.state).toBe('input-required');
|
||||
|
||||
// The scheduler now waits for approval, so no more events are sent.
|
||||
assertUniqueFinalEventIsLast(events);
|
||||
expect(events.length).toBe(7);
|
||||
expect(events.length).toBe(8);
|
||||
});
|
||||
|
||||
it('should handle multiple tool calls sequentially in YOLO mode', async () => {
|
||||
@@ -499,7 +513,7 @@ describe('E2E Tests', () => {
|
||||
// Tool 1 Lifecycle
|
||||
{
|
||||
kind: 'tool-call-update',
|
||||
status: 'validating',
|
||||
status: 'scheduled',
|
||||
callId: 'test-call-id-1',
|
||||
},
|
||||
{
|
||||
@@ -520,7 +534,7 @@ describe('E2E Tests', () => {
|
||||
// Tool 2 Lifecycle
|
||||
{
|
||||
kind: 'tool-call-update',
|
||||
status: 'validating',
|
||||
status: 'scheduled',
|
||||
callId: 'test-call-id-2',
|
||||
},
|
||||
{
|
||||
@@ -603,26 +617,40 @@ describe('E2E Tests', () => {
|
||||
expect(workingEvent2.kind).toBe('status-update');
|
||||
expect(workingEvent2.status.state).toBe('working');
|
||||
|
||||
// Status update: tool-call-update (validating)
|
||||
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
|
||||
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
// Status update: tool-call-update (scheduled)
|
||||
const scheduledEvent1 = events[3].result as TaskStatusUpdateEvent;
|
||||
expect(scheduledEvent1.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
expect(validatingEvent.status.message?.parts).toMatchObject([
|
||||
expect(scheduledEvent1.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
status: 'validating',
|
||||
status: 'scheduled',
|
||||
request: { callId: 'test-call-id-no-approval' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Status update: tool-call-update (scheduled)
|
||||
const scheduledEvent = events[4].result as TaskStatusUpdateEvent;
|
||||
expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
const scheduledEvent2 = events[4].result as TaskStatusUpdateEvent;
|
||||
expect(scheduledEvent2.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
expect(scheduledEvent.status.message?.parts).toMatchObject([
|
||||
expect(scheduledEvent2.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
status: 'scheduled',
|
||||
request: { callId: 'test-call-id-no-approval' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Status update: tool-call-update (scheduled)
|
||||
const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
|
||||
expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
expect(scheduledEvent3.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
status: 'scheduled',
|
||||
@@ -632,7 +660,7 @@ describe('E2E Tests', () => {
|
||||
]);
|
||||
|
||||
// Status update: tool-call-update (executing)
|
||||
const executingEvent = events[5].result as TaskStatusUpdateEvent;
|
||||
const executingEvent = events[6].result as TaskStatusUpdateEvent;
|
||||
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
@@ -646,7 +674,7 @@ describe('E2E Tests', () => {
|
||||
]);
|
||||
|
||||
// Status update: tool-call-update (success)
|
||||
const successEvent = events[6].result as TaskStatusUpdateEvent;
|
||||
const successEvent = events[7].result as TaskStatusUpdateEvent;
|
||||
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
@@ -660,12 +688,12 @@ describe('E2E Tests', () => {
|
||||
]);
|
||||
|
||||
// Status update: working (before sending tool result to LLM)
|
||||
const workingEvent3 = events[7].result as TaskStatusUpdateEvent;
|
||||
const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
|
||||
expect(workingEvent3.kind).toBe('status-update');
|
||||
expect(workingEvent3.status.state).toBe('working');
|
||||
|
||||
// Status update: text-content (final LLM response)
|
||||
const textContentEvent = events[8].result as TaskStatusUpdateEvent;
|
||||
const textContentEvent = events[9].result as TaskStatusUpdateEvent;
|
||||
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'text-content',
|
||||
});
|
||||
@@ -674,7 +702,7 @@ describe('E2E Tests', () => {
|
||||
]);
|
||||
|
||||
assertUniqueFinalEventIsLast(events);
|
||||
expect(events.length).toBe(10);
|
||||
expect(events.length).toBe(11);
|
||||
});
|
||||
|
||||
it('should bypass tool approval in YOLO mode', async () => {
|
||||
@@ -734,15 +762,15 @@ describe('E2E Tests', () => {
|
||||
expect(workingEvent2.kind).toBe('status-update');
|
||||
expect(workingEvent2.status.state).toBe('working');
|
||||
|
||||
// Status update: tool-call-update (validating)
|
||||
const validatingEvent = events[3].result as TaskStatusUpdateEvent;
|
||||
expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
// Status update: tool-call-update (scheduled)
|
||||
const scheduledEvent = events[3].result as TaskStatusUpdateEvent;
|
||||
expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
expect(validatingEvent.status.message?.parts).toMatchObject([
|
||||
expect(scheduledEvent.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
status: 'validating',
|
||||
status: 'scheduled',
|
||||
request: { callId: 'test-call-id-yolo' },
|
||||
},
|
||||
},
|
||||
@@ -762,8 +790,22 @@ describe('E2E Tests', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// Status update: tool-call-update (scheduled)
|
||||
const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent;
|
||||
expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
expect(scheduledEvent3.status.message?.parts).toMatchObject([
|
||||
{
|
||||
data: {
|
||||
status: 'scheduled',
|
||||
request: { callId: 'test-call-id-yolo' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Status update: tool-call-update (executing)
|
||||
const executingEvent = events[5].result as TaskStatusUpdateEvent;
|
||||
const executingEvent = events[6].result as TaskStatusUpdateEvent;
|
||||
expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
@@ -777,7 +819,7 @@ describe('E2E Tests', () => {
|
||||
]);
|
||||
|
||||
// Status update: tool-call-update (success)
|
||||
const successEvent = events[6].result as TaskStatusUpdateEvent;
|
||||
const successEvent = events[7].result as TaskStatusUpdateEvent;
|
||||
expect(successEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'tool-call-update',
|
||||
});
|
||||
@@ -791,12 +833,12 @@ describe('E2E Tests', () => {
|
||||
]);
|
||||
|
||||
// Status update: working (before sending tool result to LLM)
|
||||
const workingEvent3 = events[7].result as TaskStatusUpdateEvent;
|
||||
const workingEvent3 = events[8].result as TaskStatusUpdateEvent;
|
||||
expect(workingEvent3.kind).toBe('status-update');
|
||||
expect(workingEvent3.status.state).toBe('working');
|
||||
|
||||
// Status update: text-content (final LLM response)
|
||||
const textContentEvent = events[8].result as TaskStatusUpdateEvent;
|
||||
const textContentEvent = events[9].result as TaskStatusUpdateEvent;
|
||||
expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
|
||||
kind: 'text-content',
|
||||
});
|
||||
@@ -805,7 +847,7 @@ describe('E2E Tests', () => {
|
||||
]);
|
||||
|
||||
assertUniqueFinalEventIsLast(events);
|
||||
expect(events.length).toBe(10);
|
||||
expect(events.length).toBe(11);
|
||||
});
|
||||
|
||||
it('should include traceId in status updates when available', async () => {
|
||||
|
||||
@@ -33,6 +33,10 @@ export class GeminiAgent {
|
||||
this.sessionManager = new AcpSessionManager(settings, argv, connection);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.sessionManager.dispose();
|
||||
}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
|
||||
@@ -13,21 +13,22 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import { Session } from './acpSession.js';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
StreamEventType,
|
||||
ReadManyFilesTool,
|
||||
type GeminiChat,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
LlmRole,
|
||||
type GitService,
|
||||
type ModelRouterService,
|
||||
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';
|
||||
@@ -57,11 +58,23 @@ vi.mock(
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function* createMockStream(items: any[]) {
|
||||
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', () => {
|
||||
@@ -72,6 +85,13 @@ describe('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 = {
|
||||
@@ -97,6 +117,7 @@ describe('Session', () => {
|
||||
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'),
|
||||
@@ -124,6 +145,11 @@ describe('Session', () => {
|
||||
}),
|
||||
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;
|
||||
},
|
||||
@@ -176,11 +202,11 @@ describe('Session', () => {
|
||||
it('should await MCP initialization before processing a prompt', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [{ content: { parts: [{ text: 'Hi' }] } }] },
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hi',
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
@@ -193,20 +219,18 @@ describe('Session', () => {
|
||||
it('should handle prompt with text response', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
|
||||
},
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(mockSendMessageStream).toHaveBeenCalled();
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
@@ -217,41 +241,40 @@ describe('Session', () => {
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should use model router to determine model', async () => {
|
||||
const mockRouter = {
|
||||
route: vi.fn().mockResolvedValue({ model: 'routed-model' }),
|
||||
} as unknown as ModelRouterService;
|
||||
mockConfig.getModelRouterService.mockReturnValue(mockRouter);
|
||||
|
||||
it('should pass current session information directly onto geminiClient.sendMessageStream', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
|
||||
},
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Hello',
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockRouter.route).toHaveBeenCalled();
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'routed-model' }),
|
||||
expect.any(Array),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect(mockSendMessageStream).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([{ text: 'Hi' }]),
|
||||
expect.any(AbortSignal),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
|
||||
);
|
||||
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',
|
||||
@@ -262,9 +285,21 @@ describe('Session', () => {
|
||||
});
|
||||
|
||||
it('should handle prompt with no finish reason (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('No finish reason', 'NO_FINISH_REASON'),
|
||||
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',
|
||||
@@ -298,24 +333,26 @@ describe('Session', () => {
|
||||
it('should handle tool calls', async () => {
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: { foo: 'bar' } }],
|
||||
callId: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: { foo: 'bar' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Result' }] } }],
|
||||
},
|
||||
type: GeminiEventType.Content,
|
||||
value: 'Result',
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
mockSendMessageStream
|
||||
.mockReturnValueOnce(stream1)
|
||||
.mockReturnValueOnce(stream2);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
@@ -347,22 +384,26 @@ describe('Session', () => {
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
callId: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
type: GeminiEventType.Content,
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
mockSendMessageStream
|
||||
.mockReturnValueOnce(stream1)
|
||||
.mockReturnValueOnce(stream2);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
@@ -381,11 +422,11 @@ describe('Session', () => {
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
type: GeminiEventType.Content,
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
mockSendMessageStream.mockReturnValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
@@ -402,23 +443,33 @@ describe('Session', () => {
|
||||
|
||||
expect(path.resolve).toHaveBeenCalled();
|
||||
expect(fs.stat).toHaveBeenCalled();
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect(mockSendMessageStream).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('Content from @file.txt'),
|
||||
}),
|
||||
]),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal),
|
||||
LlmRole.MAIN,
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rate limit error', async () => {
|
||||
const error = new Error('Rate limit');
|
||||
(error as unknown as { status: number }).status = 429;
|
||||
mockChat.sendMessageStream.mockRejectedValue(error);
|
||||
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({
|
||||
@@ -436,28 +487,103 @@ describe('Session', () => {
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: {
|
||||
functionCalls: [{ name: 'unknown_tool', args: {} }],
|
||||
callId: 'call-1',
|
||||
name: 'unknown_tool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
type: GeminiEventType.Content,
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
mockSendMessageStream
|
||||
.mockReturnValueOnce(stream1)
|
||||
.mockReturnValueOnce(stream2);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
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}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,35 +6,37 @@
|
||||
|
||||
import {
|
||||
type ApprovalMode,
|
||||
type GeminiChat,
|
||||
type ToolResult,
|
||||
type ConversationRecord,
|
||||
CoreToolCallStatus,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
type ApprovalModeChangedPayload,
|
||||
logToolCall,
|
||||
convertToFunctionResponse,
|
||||
ToolConfirmationOutcome,
|
||||
isWithinRoot,
|
||||
getErrorStatus,
|
||||
DiscoveredMCPTool,
|
||||
StreamEventType,
|
||||
ToolCallEvent,
|
||||
debugLogger,
|
||||
ReadManyFilesTool,
|
||||
REFERENCE_CONTENT_START,
|
||||
type RoutingContext,
|
||||
partListUnionToString,
|
||||
LlmRole,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
type AgentLoopContext,
|
||||
updatePolicy,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
type FilterFilesOptions,
|
||||
isTextPart,
|
||||
GeminiEventType,
|
||||
type ToolCallRequestInfo,
|
||||
type GeminiChat,
|
||||
type ToolResult,
|
||||
isWithinRoot,
|
||||
processSingleFileContent,
|
||||
isNodeError,
|
||||
REFERENCE_CONTENT_START,
|
||||
InvalidStreamError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import type { Content, Part, FunctionCall } from '@google/genai';
|
||||
import type { Part, FunctionCall } from '@google/genai';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
@@ -50,6 +52,11 @@ import {
|
||||
import { z } from 'zod';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
|
||||
const StructuredErrorSchema = z.object({
|
||||
status: z.number().optional(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export class Session {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
private commandHandler = new CommandHandler();
|
||||
@@ -65,7 +72,31 @@ export class Session {
|
||||
private readonly context: AgentLoopContext,
|
||||
private readonly connection: acp.AgentSideConnection,
|
||||
private readonly settings: LoadedSettings,
|
||||
) {}
|
||||
) {
|
||||
coreEvents.on(
|
||||
CoreEvent.ApprovalModeChanged,
|
||||
this.handleApprovalModeChanged,
|
||||
);
|
||||
}
|
||||
|
||||
private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => {
|
||||
if (payload.sessionId === this.id) {
|
||||
void this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `[MODE_UPDATE] ${payload.mode}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
coreEvents.off(
|
||||
CoreEvent.ApprovalModeChanged,
|
||||
this.handleApprovalModeChanged,
|
||||
);
|
||||
}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
if (!this.pendingPrompt) {
|
||||
@@ -188,7 +219,6 @@ export class Session {
|
||||
await this.context.config.waitForMcpInit();
|
||||
|
||||
const promptId = Math.random().toString(16).slice(2);
|
||||
const chat = this.chat;
|
||||
|
||||
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
|
||||
@@ -236,100 +266,125 @@ export class Session {
|
||||
let totalOutputTokens = 0;
|
||||
const modelUsageMap = new Map<string, { input: number; output: number }>();
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
let currentParts: Part[] = parts;
|
||||
let turnCount = 0;
|
||||
const maxTurns = this.context.config.getMaxSessionTurns();
|
||||
|
||||
while (nextMessage !== null) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
chat.addHistory(nextMessage);
|
||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (maxTurns >= 0 && turnCount > maxTurns) {
|
||||
return {
|
||||
stopReason: 'max_turn_requests',
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: {
|
||||
input_tokens: totalInputTokens,
|
||||
output_tokens: totalOutputTokens,
|
||||
},
|
||||
model_usage: Array.from(modelUsageMap.entries()).map(
|
||||
([modelName, counts]) => ({
|
||||
model: modelName,
|
||||
token_count: {
|
||||
input_tokens: counts.input,
|
||||
output_tokens: counts.output,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
if (pendingSend.signal.aborted) {
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
let stopReason: acp.StopReason = 'end_turn';
|
||||
let turnModelId = this.context.config.getModel();
|
||||
let turnInputTokens = 0;
|
||||
let turnOutputTokens = 0;
|
||||
|
||||
try {
|
||||
const routingContext: RoutingContext = {
|
||||
history: chat.getHistory(/*curated=*/ true),
|
||||
request: nextMessage?.parts ?? [],
|
||||
signal: pendingSend.signal,
|
||||
requestedModel: this.context.config.getModel(),
|
||||
};
|
||||
|
||||
const router = this.context.config.getModelRouterService();
|
||||
const { model } = await router.route(routingContext);
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
{ model },
|
||||
nextMessage?.parts ?? [],
|
||||
promptId,
|
||||
const responseStream = this.context.geminiClient.sendMessageStream(
|
||||
currentParts,
|
||||
pendingSend.signal,
|
||||
LlmRole.MAIN,
|
||||
promptId,
|
||||
);
|
||||
nextMessage = null;
|
||||
|
||||
let turnInputTokens = 0;
|
||||
let turnOutputTokens = 0;
|
||||
let turnModelId = model;
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
for await (const event of responseStream) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
turnInputTokens =
|
||||
resp.value.usageMetadata.promptTokenCount ?? turnInputTokens;
|
||||
turnOutputTokens =
|
||||
resp.value.usageMetadata.candidatesTokenCount ?? turnOutputTokens;
|
||||
if (resp.value.modelVersion) {
|
||||
turnModelId = resp.value.modelVersion;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.candidates &&
|
||||
resp.value.candidates.length > 0
|
||||
) {
|
||||
const candidate = resp.value.candidates[0];
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (!part.text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case GeminiEventType.Content: {
|
||||
const content: acp.ContentBlock = {
|
||||
type: 'text',
|
||||
text: part.text,
|
||||
text: event.value,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.sendUpdate({
|
||||
sessionUpdate: part.thought
|
||||
? 'agent_thought_chunk'
|
||||
: 'agent_message_chunk',
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case GeminiEventType.Thought: {
|
||||
const thoughtText = `**${event.value.subject}**\n${event.value.description}`;
|
||||
await this.sendUpdate({
|
||||
sessionUpdate: 'agent_thought_chunk',
|
||||
content: { type: 'text', text: thoughtText },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case GeminiEventType.ToolCallRequest:
|
||||
toolCallRequests.push(event.value);
|
||||
break;
|
||||
|
||||
case GeminiEventType.Finished: {
|
||||
const usage = event.value.usageMetadata;
|
||||
if (usage) {
|
||||
turnInputTokens = usage.promptTokenCount ?? turnInputTokens;
|
||||
turnOutputTokens =
|
||||
usage.candidatesTokenCount ?? turnOutputTokens;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case GeminiEventType.ModelInfo:
|
||||
turnModelId = event.value;
|
||||
break;
|
||||
|
||||
case GeminiEventType.MaxSessionTurns:
|
||||
stopReason = 'max_turn_requests';
|
||||
break;
|
||||
|
||||
case GeminiEventType.LoopDetected:
|
||||
stopReason = 'max_turn_requests';
|
||||
break;
|
||||
|
||||
case GeminiEventType.ContextWindowWillOverflow:
|
||||
stopReason = 'max_tokens';
|
||||
break;
|
||||
|
||||
case GeminiEventType.Error: {
|
||||
const parseResult = StructuredErrorSchema.safeParse(
|
||||
event.value.error,
|
||||
);
|
||||
const errData = parseResult.success ? parseResult.data : {};
|
||||
|
||||
throw new acp.RequestError(
|
||||
errData.status ?? 500,
|
||||
errData.message ?? 'Unknown stream execution error.',
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
}
|
||||
|
||||
totalInputTokens += turnInputTokens;
|
||||
totalOutputTokens += turnOutputTokens;
|
||||
|
||||
if (turnInputTokens > 0 || turnOutputTokens > 0) {
|
||||
const existing = modelUsageMap.get(turnModelId) ?? {
|
||||
input: 0,
|
||||
output: 0,
|
||||
};
|
||||
existing.input += turnInputTokens;
|
||||
existing.output += turnOutputTokens;
|
||||
modelUsageMap.set(turnModelId, existing);
|
||||
}
|
||||
|
||||
if (pendingSend.signal.aborted) {
|
||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
||||
}
|
||||
} catch (error) {
|
||||
if (getErrorStatus(error) === 429) {
|
||||
@@ -343,7 +398,11 @@ export class Session {
|
||||
pendingSend.signal.aborted ||
|
||||
(error instanceof Error && error.name === 'AbortError')
|
||||
) {
|
||||
return { stopReason: CoreToolCallStatus.Cancelled };
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
if (error instanceof acp.RequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -386,16 +445,59 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
totalInputTokens += turnInputTokens;
|
||||
totalOutputTokens += turnOutputTokens;
|
||||
|
||||
for (const fc of functionCalls) {
|
||||
const response = await this.runTool(pendingSend.signal, promptId, fc);
|
||||
toolResponseParts.push(...response);
|
||||
}
|
||||
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
if (turnInputTokens > 0 || turnOutputTokens > 0) {
|
||||
const existing = modelUsageMap.get(turnModelId) ?? {
|
||||
input: 0,
|
||||
output: 0,
|
||||
};
|
||||
existing.input += turnInputTokens;
|
||||
existing.output += turnOutputTokens;
|
||||
modelUsageMap.set(turnModelId, existing);
|
||||
}
|
||||
|
||||
if (stopReason !== 'end_turn') {
|
||||
return {
|
||||
stopReason,
|
||||
_meta: {
|
||||
quota: {
|
||||
token_count: {
|
||||
input_tokens: totalInputTokens,
|
||||
output_tokens: totalOutputTokens,
|
||||
},
|
||||
model_usage: Array.from(modelUsageMap.entries()).map(
|
||||
([modelName, counts]) => ({
|
||||
model: modelName,
|
||||
token_count: {
|
||||
input_tokens: counts.input,
|
||||
output_tokens: counts.output,
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (toolCallRequests.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const toolResponseParts: Part[] = [];
|
||||
for (const tReq of toolCallRequests) {
|
||||
const fc: FunctionCall = {
|
||||
id: tReq.callId,
|
||||
name: tReq.name,
|
||||
args: tReq.args,
|
||||
};
|
||||
|
||||
const response = await this.runTool(pendingSend.signal, promptId, fc);
|
||||
toolResponseParts.push(...response);
|
||||
}
|
||||
|
||||
currentParts = toolResponseParts;
|
||||
}
|
||||
|
||||
const modelUsageArray = Array.from(modelUsageMap.entries()).map(
|
||||
|
||||
@@ -48,6 +48,13 @@ export class AcpSessionManager {
|
||||
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,
|
||||
@@ -183,6 +190,12 @@ export class AcpSessionManager {
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const existingSession = this.sessions.get(sessionId);
|
||||
if (existingSession) {
|
||||
existingSession.dispose();
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Stream history back to client
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
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')}`,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -1174,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', () => {
|
||||
@@ -3988,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', '');
|
||||
@@ -4000,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(
|
||||
@@ -4012,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,
|
||||
@@ -558,6 +559,8 @@ export interface LoadCliConfigOptions {
|
||||
disabled?: string[];
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
skipExtensions?: boolean;
|
||||
skipMemoryLoad?: boolean;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -566,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 =
|
||||
@@ -641,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;
|
||||
|
||||
@@ -673,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,
|
||||
@@ -681,7 +695,7 @@ export async function loadCliConfig(
|
||||
? includeDirectories
|
||||
: [],
|
||||
fileService,
|
||||
extensionManager,
|
||||
finalExtensionLoader,
|
||||
trustedFolder,
|
||||
memoryImportFormat,
|
||||
memoryFileFiltering,
|
||||
@@ -931,7 +945,13 @@ 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.
|
||||
@@ -1031,7 +1051,7 @@ 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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
FatalConfigError,
|
||||
GEMINI_DIR,
|
||||
Storage,
|
||||
AuthType,
|
||||
type MCPServerConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
|
||||
@@ -202,6 +203,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('loadSettings', () => {
|
||||
@@ -3036,6 +3038,7 @@ describe('Settings Loading and Merging', () => {
|
||||
delete process.env['CLOUD_SHELL'];
|
||||
delete process.env['MALICIOUS_VAR'];
|
||||
delete process.env['FOO'];
|
||||
delete process.env['_GEMINI_USER_GCP_PROJECT'];
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
@@ -3268,6 +3271,134 @@ MALICIOUS_VAR=allowed-because-trusted
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
|
||||
});
|
||||
|
||||
it('should not override GOOGLE_CLOUD_PROJECT in Cloud Shell when auth type is vertex-ai', () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-vertex-project');
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
// No .env file
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-vertex-project');
|
||||
});
|
||||
|
||||
it('should respect .env override for GOOGLE_CLOUD_PROJECT in Cloud Shell when auth type is vertex-ai', () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-vertex-project');
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
|
||||
// Mock .env file to override the shell project
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(
|
||||
'GOOGLE_CLOUD_PROJECT=env-vertex-project',
|
||||
);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('env-vertex-project');
|
||||
});
|
||||
|
||||
it('should clear cloudshell-gca when switching to Vertex AI without an original project', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// First call: normal Cloud Shell auth sets cloudshell-gca
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
|
||||
|
||||
// Second call: user switched to Vertex AI, should remove cloudshell-gca
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should restore original project when switching to Vertex AI after Cloud Shell override', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'my-real-project';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// First call: saves original to _GEMINI_USER_GCP_PROJECT, sets cloudshell-gca
|
||||
loadEnvironment(
|
||||
createMockSettings({ tools: { sandbox: false } }).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
|
||||
expect(process.env['_GEMINI_USER_GCP_PROJECT']).toBe('my-real-project');
|
||||
|
||||
// Second call: switching to Vertex AI should restore the saved value
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-real-project');
|
||||
});
|
||||
|
||||
it('should restore project after restart when child inherits cloudshell-gca', () => {
|
||||
// Simulate child process after restart: inherits cloudshell-gca and
|
||||
// the saved original from the parent process.
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
|
||||
process.env['_GEMINI_USER_GCP_PROJECT'] = 'my-real-project';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: 'file',
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
loadEnvironment(
|
||||
createMockSettings({
|
||||
tools: { sandbox: false },
|
||||
security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
|
||||
}).merged,
|
||||
MOCK_WORKSPACE_DIR,
|
||||
);
|
||||
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-real-project');
|
||||
});
|
||||
|
||||
it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => {
|
||||
process.env['CLOUD_SHELL'] = 'true';
|
||||
process.argv = ['node', 'gemini', '-s', 'prompt'];
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Storage,
|
||||
coreEvents,
|
||||
homedir,
|
||||
AuthType,
|
||||
type AdminControlsSettings,
|
||||
createCache,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -499,7 +500,11 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
function findEnvFile(
|
||||
startDir: string,
|
||||
isTrusted: boolean,
|
||||
ignoreLocalEnv: boolean,
|
||||
): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
@@ -511,7 +516,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
return envPath;
|
||||
if (!ignoreLocalEnv || currentDir === homedir()) {
|
||||
return envPath;
|
||||
}
|
||||
}
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
@@ -532,17 +539,41 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Internal env var used to preserve the user's original GOOGLE_CLOUD_PROJECT
|
||||
// across process restarts in Cloud Shell. This survives relaunch because child
|
||||
// processes inherit the parent's environment.
|
||||
const USER_GCP_PROJECT = '_GEMINI_USER_GCP_PROJECT';
|
||||
|
||||
export function setUpCloudShellEnvironment(
|
||||
envFilePath: string | null,
|
||||
isTrusted: boolean,
|
||||
isSandboxed: boolean,
|
||||
selectedAuthType?: string,
|
||||
): void {
|
||||
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
|
||||
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
|
||||
// set by the user using "gcloud config set project" we do not want to
|
||||
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
|
||||
// one of the .env files, we set the Cloud Shell-specific default here.
|
||||
let value = 'cloudshell-gca';
|
||||
//
|
||||
// However, if the user has explicitly selected Vertex AI auth, they intend
|
||||
// to use their own GCP project, so we restore the original value and skip
|
||||
// the Cloud Shell override to respect their .env settings.
|
||||
|
||||
// Save the user's original value before overwriting, so it can be restored
|
||||
// if the user later switches to Vertex AI (even after a process restart).
|
||||
if (!process.env[USER_GCP_PROJECT]) {
|
||||
const current = process.env['GOOGLE_CLOUD_PROJECT'];
|
||||
if (current && current !== 'cloudshell-gca') {
|
||||
process.env[USER_GCP_PROJECT] = current;
|
||||
}
|
||||
}
|
||||
|
||||
let value: string | undefined = 'cloudshell-gca';
|
||||
|
||||
if (selectedAuthType === AuthType.USE_VERTEX_AI) {
|
||||
value = process.env[USER_GCP_PROJECT];
|
||||
}
|
||||
|
||||
if (envFilePath && fs.existsSync(envFilePath)) {
|
||||
const envFileContent = fs.readFileSync(envFilePath);
|
||||
@@ -555,7 +586,12 @@ export function setUpCloudShellEnvironment(
|
||||
}
|
||||
}
|
||||
}
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = value;
|
||||
|
||||
if (value !== undefined) {
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] = value;
|
||||
} else if (process.env['GOOGLE_CLOUD_PROJECT'] === 'cloudshell-gca') {
|
||||
delete process.env['GOOGLE_CLOUD_PROJECT'];
|
||||
}
|
||||
}
|
||||
|
||||
export function loadEnvironment(
|
||||
@@ -565,7 +601,6 @@ export function loadEnvironment(
|
||||
): void {
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted);
|
||||
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
@@ -582,9 +617,21 @@ export function loadEnvironment(
|
||||
relevantArgs.includes('-s') ||
|
||||
relevantArgs.includes('--sandbox');
|
||||
|
||||
const shouldIgnoreEnv =
|
||||
!!settings.advanced?.ignoreLocalEnv ||
|
||||
relevantArgs.includes('--ignore-env');
|
||||
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted, shouldIgnoreEnv);
|
||||
|
||||
// Cloud Shell environment variable handling
|
||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
||||
const selectedAuthType = settings.security?.auth?.selectedType;
|
||||
setUpCloudShellEnvironment(
|
||||
envFilePath,
|
||||
isTrusted,
|
||||
isSandboxed,
|
||||
selectedAuthType,
|
||||
);
|
||||
}
|
||||
|
||||
if (envFilePath) {
|
||||
|
||||
@@ -2030,6 +2030,16 @@ const SETTINGS_SCHEMA = {
|
||||
items: { type: 'string' },
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
ignoreLocalEnv: {
|
||||
type: 'boolean',
|
||||
label: 'Ignore Local .env',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Whether to ignore generic .env files in the project directory.',
|
||||
showInDialog: true,
|
||||
},
|
||||
bugCommand: {
|
||||
type: 'object',
|
||||
label: 'Bug Command',
|
||||
@@ -2057,8 +2067,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Gemma Models',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable access to Gemma 4 models (experimental).',
|
||||
default: true,
|
||||
description: 'Enable access to Gemma 4 models via Gemini API.',
|
||||
showInDialog: true,
|
||||
},
|
||||
voiceMode: {
|
||||
@@ -2099,7 +2109,11 @@ const SETTINGS_SCHEMA = {
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'gemini-live',
|
||||
description: 'The backend to use for voice transcription.',
|
||||
description: oneLine`
|
||||
The backend to use for voice transcription. Note: When using the
|
||||
Gemini Live backend, voice recordings are sent to Google Cloud for
|
||||
transcription.
|
||||
`,
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'gemini-live', label: 'Gemini Live API (Cloud)' },
|
||||
@@ -2406,7 +2420,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox.',
|
||||
'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.',
|
||||
showInDialog: true,
|
||||
},
|
||||
generalistProfile: {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { loadCliConfig, type CliArgs } from './config.js';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
|
||||
vi.mock('./extension-manager.js', () => ({
|
||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
||||
loadExtensions: vi.fn().mockResolvedValue([]),
|
||||
getExtensions: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('loadCliConfig skipExtensions', () => {
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = {
|
||||
query: undefined,
|
||||
model: undefined,
|
||||
sandbox: undefined,
|
||||
debug: undefined,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
listSessions: undefined,
|
||||
} as unknown as CliArgs;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should load extensions by default', async () => {
|
||||
await loadCliConfig(settings, 'session-id', argv);
|
||||
expect(ExtensionManager).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip extensions when skipExtensions is true', async () => {
|
||||
await loadCliConfig(settings, 'session-id', argv, { skipExtensions: true });
|
||||
expect(ExtensionManager).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+44
-21
@@ -13,6 +13,7 @@ import {
|
||||
type OutputPayload,
|
||||
type ConsoleLogPayload,
|
||||
type UserFeedbackPayload,
|
||||
type CoreEvents,
|
||||
createSessionId,
|
||||
logUserPrompt,
|
||||
AuthType,
|
||||
@@ -261,6 +262,7 @@ export async function startInteractiveUI(
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
let config: Config | undefined;
|
||||
const cliStartupHandle = startupProfiler.start('cli_startup');
|
||||
|
||||
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
|
||||
@@ -273,7 +275,7 @@ export async function main() {
|
||||
const cleanupStdio = patchStdio();
|
||||
registerSyncCleanup(() => {
|
||||
// This is needed to ensure we don't lose any buffered output.
|
||||
initializeOutputListenersAndFlush();
|
||||
initializeOutputListenersAndFlush(config);
|
||||
cleanupStdio();
|
||||
});
|
||||
|
||||
@@ -409,6 +411,8 @@ export async function main() {
|
||||
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
skipExtensions: true,
|
||||
skipMemoryLoad: true,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
@@ -534,7 +538,7 @@ export async function main() {
|
||||
// may have side effects.
|
||||
{
|
||||
const loadConfigHandle = startupProfiler.start('load_cli_config');
|
||||
const config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
worktreeSettings: worktreeInfo,
|
||||
});
|
||||
@@ -780,7 +784,7 @@ export async function main() {
|
||||
debugLogger.log('Session ID: %s', sessionId);
|
||||
}
|
||||
|
||||
initializeOutputListenersAndFlush();
|
||||
initializeOutputListenersAndFlush(config);
|
||||
|
||||
await runNonInteractive({
|
||||
config,
|
||||
@@ -795,7 +799,7 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeOutputListenersAndFlush() {
|
||||
export function initializeOutputListenersAndFlush(config?: Config) {
|
||||
// If there are no listeners for output, make sure we flush so output is not
|
||||
// lost.
|
||||
if (coreEvents.listenerCount(CoreEvent.Output) === 0) {
|
||||
@@ -807,24 +811,43 @@ export function initializeOutputListenersAndFlush() {
|
||||
writeToStdout(payload.chunk, payload.encoding);
|
||||
}
|
||||
});
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
|
||||
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
|
||||
if (payload.type === 'error' || payload.type === 'warn') {
|
||||
writeToStderr(payload.content + '\n');
|
||||
} else {
|
||||
writeToStderr(payload.content + '\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
|
||||
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
|
||||
writeToStderr(payload.message + '\n');
|
||||
});
|
||||
}
|
||||
}
|
||||
coreEvents.drainBacklogs();
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
|
||||
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
|
||||
if (payload.type === 'error' || payload.type === 'warn') {
|
||||
writeToStderr(payload.content + '\n');
|
||||
} else {
|
||||
writeToStderr(payload.content + '\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
|
||||
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
|
||||
writeToStderr(payload.message + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
const outputFormat = config?.getOutputFormat();
|
||||
const forceToStderr = outputFormat === 'json';
|
||||
|
||||
coreEvents.drainBacklogs(
|
||||
<K extends keyof CoreEvents>(event: K, args: CoreEvents[K]) => {
|
||||
if (forceToStderr && event === (CoreEvent.Output as string)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const payload = args[0] as OutputPayload;
|
||||
if (!payload.isStderr) {
|
||||
return {
|
||||
event,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
args: [{ ...payload, isStderr: true }] as unknown as CoreEvents[K],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { event, args };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setupAdminControlsListener() {
|
||||
|
||||
@@ -179,7 +179,12 @@ export async function startInteractiveUI(
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
handleAutoUpdate(
|
||||
info,
|
||||
settings,
|
||||
config.getProjectRoot(),
|
||||
config.getSandboxEnabled(),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FatalInputError,
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
@@ -262,7 +263,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
@@ -381,7 +381,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
@@ -541,7 +540,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
@@ -683,7 +681,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
@@ -703,7 +700,7 @@ describe('runNonInteractive', () => {
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -719,7 +716,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
@@ -793,7 +789,7 @@ describe('runNonInteractive', () => {
|
||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -836,7 +832,7 @@ describe('runNonInteractive', () => {
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -852,7 +848,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
@@ -989,7 +984,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
@@ -1035,7 +1029,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
@@ -1213,7 +1206,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
@@ -1530,7 +1522,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -1692,7 +1684,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -1726,6 +1718,53 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'loop detected',
|
||||
events: [
|
||||
{ type: GeminiEventType.LoopDetected },
|
||||
] as ServerGeminiStreamEvent[],
|
||||
expectedWarning: 'Loop detected, stopping execution',
|
||||
},
|
||||
{
|
||||
name: 'max session turns',
|
||||
events: [
|
||||
{ type: GeminiEventType.MaxSessionTurns },
|
||||
] as ServerGeminiStreamEvent[],
|
||||
expectedWarning: 'Maximum session turns exceeded',
|
||||
},
|
||||
])(
|
||||
'should include warning in JSON mode for: $name',
|
||||
async ({ events, expectedWarning }) => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const streamEvents: ServerGeminiStreamEvent[] = [
|
||||
...events,
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(streamEvents),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeDefined();
|
||||
expect(output.warnings).toContain(expectedWarning);
|
||||
},
|
||||
);
|
||||
|
||||
it('should log error when tool recording fails', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
@@ -1867,7 +1906,7 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -1931,7 +1970,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -2037,6 +2076,235 @@ describe('runNonInteractive', () => {
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
|
||||
it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
// Setup stream-json format
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test block',
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
const calls = processStdoutSpy.mock.calls.map((call) =>
|
||||
JSON.parse(call[0] as string),
|
||||
);
|
||||
const errorEvent = calls.find(
|
||||
(c) => c.type === JsonStreamEventType.ERROR,
|
||||
);
|
||||
|
||||
expect(errorEvent).toBeDefined();
|
||||
expect(errorEvent.message).toContain(
|
||||
'Agent execution blocked: Blocked by hook',
|
||||
);
|
||||
expect(errorEvent.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test block',
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeDefined();
|
||||
expect(output.warnings).toContain(
|
||||
'Agent execution blocked: Blocked by hook',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Block 1', systemMessage: 'Reason 1' },
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Block 2', systemMessage: 'Reason 2' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() =>
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toHaveLength(2);
|
||||
expect(output.warnings).toContain('Agent execution blocked: Reason 1');
|
||||
expect(output.warnings).toContain('Agent execution blocked: Reason 2');
|
||||
});
|
||||
|
||||
it('should not include warnings field in JSON output if no blocks occur', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Clean answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() =>
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[ERROR] Invalid stream: The model returned an empty response or malformed tool call.\n',
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"error"');
|
||||
expect(output).toContain(
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.',
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle InvalidStream event gracefully in JSON mode', async () => {
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
|
||||
OutputFormat.JSON,
|
||||
);
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.InvalidStream },
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test invalid stream',
|
||||
prompt_id: 'prompt-id-invalid',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
expect(output).toContain('"error": {');
|
||||
expect(output).toContain('"type": "INVALID_STREAM"');
|
||||
expect(output).toContain(
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.',
|
||||
);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Sanitization', () => {
|
||||
@@ -2218,7 +2486,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
|
||||
@@ -56,6 +56,13 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the non-interactive CLI loop.
|
||||
*
|
||||
* Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization
|
||||
* by stripping ANSI escape sequences from messages to ensure clean,
|
||||
* parseable output for downstream consumers.
|
||||
*/
|
||||
export async function runNonInteractive(
|
||||
params: RunNonInteractiveParams,
|
||||
): Promise<void> {
|
||||
@@ -295,6 +302,8 @@ export async function runNonInteractive(
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
||||
|
||||
let turnCount = 0;
|
||||
let invalidStreamError: string | undefined;
|
||||
const warnings: string[] = [];
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
@@ -310,7 +319,6 @@ export async function runNonInteractive(
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
);
|
||||
|
||||
@@ -351,23 +359,27 @@ export async function runNonInteractive(
|
||||
}
|
||||
toolCallRequests.push(event.value);
|
||||
} else if (event.type === GeminiEventType.LoopDetected) {
|
||||
const message = 'Loop detected, stopping execution';
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: 'Loop detected, stopping execution',
|
||||
message,
|
||||
});
|
||||
}
|
||||
warnings.push(message);
|
||||
} else if (event.type === GeminiEventType.MaxSessionTurns) {
|
||||
const message = 'Maximum session turns exceeded';
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
message,
|
||||
});
|
||||
}
|
||||
warnings.push(message);
|
||||
} else if (event.type === GeminiEventType.Error) {
|
||||
throw event.value.error;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
|
||||
@@ -394,7 +406,30 @@ export async function runNonInteractive(
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
||||
} else if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: stripAnsi(blockMessage),
|
||||
});
|
||||
}
|
||||
warnings.push(blockMessage);
|
||||
} else if (event.type === GeminiEventType.InvalidStream) {
|
||||
invalidStreamError =
|
||||
'Invalid stream: The model returned an empty response or malformed tool call.';
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: invalidStreamError,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[ERROR] ${invalidStreamError}\n`);
|
||||
}
|
||||
toolCallRequests.length = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +526,13 @@ export async function runNonInteractive(
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
formatter.format(
|
||||
config.getSessionId(),
|
||||
responseText,
|
||||
stats,
|
||||
undefined,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
@@ -508,14 +549,22 @@ export async function runNonInteractive(
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
status: invalidStreamError ? 'error' : 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
formatter.format(
|
||||
config.getSessionId(),
|
||||
responseText,
|
||||
stats,
|
||||
invalidStreamError
|
||||
? { type: 'INVALID_STREAM', message: invalidStreamError }
|
||||
: undefined,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FatalInputError,
|
||||
CoreEvent,
|
||||
CoreToolCallStatus,
|
||||
JsonStreamEventType,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCliAgentSession.js';
|
||||
@@ -268,7 +269,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
@@ -435,7 +435,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
@@ -595,7 +594,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
@@ -737,7 +735,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
@@ -757,7 +754,7 @@ describe('runNonInteractive', () => {
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -773,7 +770,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
@@ -847,7 +843,7 @@ describe('runNonInteractive', () => {
|
||||
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -927,7 +923,7 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -963,7 +959,7 @@ describe('runNonInteractive', () => {
|
||||
createStreamFromEvents(events),
|
||||
);
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -979,7 +975,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
@@ -1116,7 +1111,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
@@ -1162,7 +1156,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
@@ -1382,7 +1375,6 @@ describe('runNonInteractive', () => {
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
@@ -1699,7 +1691,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -1861,7 +1853,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -1895,6 +1887,50 @@ describe('runNonInteractive', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'loop detected',
|
||||
events: [
|
||||
{ type: GeminiEventType.LoopDetected },
|
||||
] as ServerGeminiStreamEvent[],
|
||||
expectedWarning: 'Loop detected, stopping execution',
|
||||
},
|
||||
])(
|
||||
'should include warning in JSON mode for: $name',
|
||||
async ({ events, expectedWarning }) => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const streamEvents: ServerGeminiStreamEvent[] = [
|
||||
...events,
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(streamEvents),
|
||||
);
|
||||
|
||||
try {
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
} catch {
|
||||
// Expected exit for max turns
|
||||
}
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeDefined();
|
||||
expect(output.warnings[0]).toContain(expectedWarning);
|
||||
},
|
||||
);
|
||||
|
||||
it('should log error when tool recording fails', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
@@ -2034,7 +2070,7 @@ describe('runNonInteractive', () => {
|
||||
|
||||
it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -2098,7 +2134,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -2203,6 +2239,154 @@ describe('runNonInteractive', () => {
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
// Setup stream-json format
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test block',
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
const calls = processStdoutSpy.mock.calls.map((call) =>
|
||||
JSON.parse(call[0] as string),
|
||||
);
|
||||
const errorEvent = calls.find(
|
||||
(c) => c.type === JsonStreamEventType.ERROR,
|
||||
);
|
||||
|
||||
expect(errorEvent).toBeDefined();
|
||||
expect(errorEvent.message).toContain(
|
||||
'Agent execution blocked: Blocked by hook',
|
||||
);
|
||||
expect(errorEvent.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Blocked by hook' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test block',
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeDefined();
|
||||
expect(output.warnings).toContain(
|
||||
'Agent execution blocked: Blocked by hook',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Block 1', systemMessage: 'Reason 1' },
|
||||
},
|
||||
{
|
||||
type: GeminiEventType.AgentExecutionBlocked,
|
||||
value: { reason: 'Block 2', systemMessage: 'Reason 2' },
|
||||
},
|
||||
{ type: GeminiEventType.Content, value: 'Final answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() =>
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toHaveLength(2);
|
||||
expect(output.warnings).toContain('Agent execution blocked: Reason 1');
|
||||
expect(output.warnings).toContain('Agent execution blocked: Reason 2');
|
||||
});
|
||||
|
||||
it('should not include warnings field in JSON output if no blocks occur', async () => {
|
||||
const allEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.Content, value: 'Clean answer' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
|
||||
},
|
||||
];
|
||||
|
||||
mockGeminiClient.sendMessageStream.mockImplementation(() =>
|
||||
createStreamFromEvents(allEvents),
|
||||
);
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'test',
|
||||
prompt_id: 'test',
|
||||
});
|
||||
|
||||
const output = JSON.parse(getWrittenOutput());
|
||||
expect(output.warnings).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output Sanitization', () => {
|
||||
@@ -2346,7 +2530,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
@@ -2418,7 +2602,7 @@ describe('runNonInteractive', () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the non-interactive CLI using the LegacyAgentSession.
|
||||
*
|
||||
* Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization
|
||||
* by stripping ANSI escape sequences from messages to ensure clean,
|
||||
* parseable output for downstream consumers.
|
||||
*/
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
@@ -339,7 +346,13 @@ export async function runNonInteractive({
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
formatter.format(
|
||||
config.getSessionId(),
|
||||
responseText,
|
||||
stats,
|
||||
undefined,
|
||||
warnings,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline();
|
||||
@@ -420,6 +433,7 @@ export async function runNonInteractive({
|
||||
let responseText = '';
|
||||
let preToolResponseText: string | undefined;
|
||||
let streamEnded = false;
|
||||
const warnings: string[] = [];
|
||||
for await (const event of session.stream({ streamId })) {
|
||||
if (streamEnded) break;
|
||||
switch (event.type) {
|
||||
@@ -538,9 +552,18 @@ export async function runNonInteractive({
|
||||
const errorCode = event._meta?.['code'];
|
||||
|
||||
if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
|
||||
const blockMessage = `Agent execution blocked: ${event.message.trim()}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
||||
} else if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: stripAnsi(blockMessage),
|
||||
});
|
||||
}
|
||||
warnings.push(blockMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -554,9 +577,10 @@ export async function runNonInteractive({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity,
|
||||
message: event.message,
|
||||
message: stripAnsi(event.message),
|
||||
});
|
||||
}
|
||||
warnings.push(event.message);
|
||||
break;
|
||||
}
|
||||
case 'agent_end': {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { initializeOutputListenersAndFlush } from './gemini.js';
|
||||
import { coreEvents, CoreEvent, type Config } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock core dependencies
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
writeToStdout: vi.fn(),
|
||||
writeToStderr: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { writeToStdout, writeToStderr } from '@google/gemini-cli-core';
|
||||
|
||||
describe('Output Redirection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Clear listeners to simulate a clean state for each test
|
||||
coreEvents.removeAllListeners();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
coreEvents.removeAllListeners();
|
||||
});
|
||||
|
||||
it('should redirect buffered stdout to stderr when output format is json', () => {
|
||||
const mockConfig = {
|
||||
getOutputFormat: () => 'json',
|
||||
} as unknown as Config;
|
||||
|
||||
// Simulate buffered output
|
||||
coreEvents.emitOutput(false, 'informational message');
|
||||
coreEvents.emitOutput(true, 'error message');
|
||||
|
||||
// Initialize listeners and flush
|
||||
initializeOutputListenersAndFlush(mockConfig);
|
||||
|
||||
// Verify informational message was forced to stderr
|
||||
expect(writeToStderr).toHaveBeenCalledWith(
|
||||
'informational message',
|
||||
undefined,
|
||||
);
|
||||
expect(writeToStderr).toHaveBeenCalledWith('error message', undefined);
|
||||
expect(writeToStdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT redirect buffered stdout to stderr when output format is NOT json', () => {
|
||||
const mockConfig = {
|
||||
getOutputFormat: () => 'text',
|
||||
} as unknown as Config;
|
||||
|
||||
// Simulate buffered output
|
||||
coreEvents.emitOutput(false, 'regular message');
|
||||
|
||||
// Initialize listeners and flush
|
||||
initializeOutputListenersAndFlush(mockConfig);
|
||||
|
||||
// Verify regular message went to stdout
|
||||
expect(writeToStdout).toHaveBeenCalledWith('regular message', undefined);
|
||||
expect(writeToStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT force stdout to stderr when config is undefined (early init/version)', () => {
|
||||
// Simulate buffered output during early init
|
||||
coreEvents.emitOutput(false, 'early init message');
|
||||
|
||||
// Initialize with undefined config
|
||||
initializeOutputListenersAndFlush(undefined);
|
||||
|
||||
// Verify it went to stdout (default behavior)
|
||||
expect(writeToStdout).toHaveBeenCalledWith('early init message', undefined);
|
||||
expect(writeToStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should attach ConsoleLog and UserFeedback listeners even if Output already has one', () => {
|
||||
// Manually attach an Output listener
|
||||
coreEvents.on(CoreEvent.Output, vi.fn());
|
||||
|
||||
// Initialize - should still attach ConsoleLog and UserFeedback defaults
|
||||
initializeOutputListenersAndFlush(undefined);
|
||||
|
||||
// Simulate events
|
||||
coreEvents.emitConsoleLog('info', 'stray log');
|
||||
coreEvents.emitFeedback('info', 'stray feedback');
|
||||
|
||||
// Draining happens inside initializeOutputListenersAndFlush for existing backlog,
|
||||
// but here we check the newly attached listeners for immediate emission.
|
||||
expect(writeToStderr).toHaveBeenCalledWith('stray log\n');
|
||||
expect(writeToStderr).toHaveBeenCalledWith('stray feedback\n');
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,9 @@ vi.mock('../ui/commands/agentsCommand.js', () => ({
|
||||
agentsCommand: { name: 'agents' },
|
||||
}));
|
||||
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
|
||||
vi.mock('../ui/commands/bugMemoryCommand.js', () => ({
|
||||
bugMemoryCommand: { name: 'bug-memory' },
|
||||
}));
|
||||
vi.mock('../ui/commands/chatCommand.js', () => ({
|
||||
chatCommand: {
|
||||
name: 'chat',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { aboutCommand } from '../ui/commands/aboutCommand.js';
|
||||
import { agentsCommand } from '../ui/commands/agentsCommand.js';
|
||||
import { authCommand } from '../ui/commands/authCommand.js';
|
||||
import { bugCommand } from '../ui/commands/bugCommand.js';
|
||||
import { bugMemoryCommand } from '../ui/commands/bugMemoryCommand.js';
|
||||
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { commandsCommand } from '../ui/commands/commandsCommand.js';
|
||||
@@ -123,6 +124,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
|
||||
authCommand,
|
||||
bugCommand,
|
||||
bugMemoryCommand,
|
||||
{
|
||||
...chatCommand,
|
||||
subCommands: chatResumeSubCommands,
|
||||
|
||||
@@ -34,13 +34,20 @@ import {
|
||||
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
|
||||
import { sanitizeForDisplay } from '../ui/utils/textUtils.js';
|
||||
|
||||
interface CommandDirectory {
|
||||
export interface CommandDirectory {
|
||||
path: string;
|
||||
kind: CommandKind;
|
||||
extensionName?: string;
|
||||
extensionId?: string;
|
||||
}
|
||||
|
||||
export interface CommandFileGroup {
|
||||
displayName: string;
|
||||
path: string;
|
||||
files: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the Zod schema for a command definition file. This serves as the
|
||||
* single source of truth for both validation and type inference.
|
||||
@@ -141,6 +148,59 @@ export class FileCommandLoader implements ICommandLoader {
|
||||
return allCommands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists available .toml command files from user, project, and extension directories.
|
||||
*/
|
||||
async listAvailableFiles(): Promise<CommandFileGroup[]> {
|
||||
const directories = this.getCommandDirectories();
|
||||
const groups: CommandFileGroup[] = [];
|
||||
|
||||
for (const dir of directories) {
|
||||
const displayName = this.getDisplayName(dir);
|
||||
|
||||
try {
|
||||
const files = await glob('**/*.toml', { cwd: dir.path });
|
||||
if (files.length > 0) {
|
||||
groups.push({
|
||||
displayName,
|
||||
path: dir.path,
|
||||
files: [...files].sort(),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if ((e as { code?: string }).code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({
|
||||
displayName,
|
||||
path: dir.path,
|
||||
files: [],
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable display name for the command directory source.
|
||||
*/
|
||||
private getDisplayName(dir: CommandDirectory): string {
|
||||
switch (dir.kind) {
|
||||
case CommandKind.USER_FILE:
|
||||
return 'User';
|
||||
case CommandKind.WORKSPACE_FILE:
|
||||
return 'Project';
|
||||
case CommandKind.EXTENSION_FILE:
|
||||
return `Extension: ${dir.extensionName || 'Unknown'}`;
|
||||
default:
|
||||
return 'Custom';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all command directories in order for loading.
|
||||
* User commands → Project commands → Extension commands
|
||||
|
||||
@@ -135,7 +135,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getUseRipgrep: vi.fn().mockReturnValue(false),
|
||||
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
|
||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||
getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
|
||||
getRetryFetchErrors: vi.fn().mockReturnValue(true),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
|
||||
|
||||
@@ -592,6 +592,7 @@ const mockUIActions: UIActions = {
|
||||
setActiveBackgroundTaskPid: vi.fn(),
|
||||
setIsBackgroundTaskListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
dismissLoginRestart: vi.fn(),
|
||||
onHintInput: vi.fn(),
|
||||
onHintBackspace: vi.fn(),
|
||||
onHintClear: vi.fn(),
|
||||
|
||||
@@ -173,7 +173,6 @@ import {
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
EXPAND_HINT_DURATION_MS,
|
||||
} from './constants.js';
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { parseSlashCommand } from '../utils/commands.js';
|
||||
@@ -756,7 +755,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (authState === AuthState.Authenticated && authContext.requiresRestart) {
|
||||
setAuthState(AuthState.AwaitingGoogleLoginRestart);
|
||||
setAuthState(AuthState.AwaitingLoginRestart);
|
||||
setAuthContext({});
|
||||
}
|
||||
}, [authState, authContext, setAuthState]);
|
||||
@@ -875,10 +874,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
async (apiKey: string) => {
|
||||
try {
|
||||
onAuthError(null);
|
||||
if (!apiKey.trim() && apiKey.length > 1) {
|
||||
onAuthError(
|
||||
'API key cannot be empty string with length greater than 1.',
|
||||
);
|
||||
if (!apiKey.trim()) {
|
||||
onAuthError('API key cannot be empty or whitespace only.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1130,18 +1127,21 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}, [config, historyManager]);
|
||||
|
||||
const cancelHandlerRef = useRef<(shouldRestorePrompt?: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
const cancelHandlerRef = useRef<
|
||||
(shouldRestorePrompt?: boolean, clearBuffer?: boolean) => void
|
||||
>(() => {});
|
||||
|
||||
const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => {
|
||||
if (shouldRestorePrompt) {
|
||||
setPendingRestorePrompt(true);
|
||||
} else {
|
||||
setPendingRestorePrompt(false);
|
||||
cancelHandlerRef.current(false);
|
||||
}
|
||||
}, []);
|
||||
const onCancelSubmit = useCallback(
|
||||
(shouldRestorePrompt?: boolean, clearBuffer: boolean = false) => {
|
||||
if (shouldRestorePrompt) {
|
||||
setPendingRestorePrompt(true);
|
||||
} else {
|
||||
setPendingRestorePrompt(false);
|
||||
cancelHandlerRef.current(false, clearBuffer);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingRestorePrompt) {
|
||||
@@ -1324,18 +1324,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
});
|
||||
|
||||
cancelHandlerRef.current = useCallback(
|
||||
(shouldRestorePrompt: boolean = true) => {
|
||||
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||
(shouldRestorePrompt: boolean = true, clearBuffer: boolean = false) => {
|
||||
if (!clearBuffer && isToolAwaitingConfirmation(pendingHistoryItems)) {
|
||||
return; // Don't clear - user may be composing a follow-up message
|
||||
}
|
||||
if (isToolExecuting(pendingHistoryItems)) {
|
||||
buffer.setText(''); // Clear for Ctrl+C cancellation
|
||||
return;
|
||||
}
|
||||
|
||||
// If cancelling (shouldRestorePrompt=false), never modify the buffer
|
||||
// User is in control - preserve whatever text they typed, pasted, or restored
|
||||
// If cancelling (shouldRestorePrompt=false):
|
||||
if (!shouldRestorePrompt) {
|
||||
// Clear the buffer if explicitly requested (e.g., Ctrl+C)
|
||||
if (clearBuffer) {
|
||||
buffer.setText('');
|
||||
}
|
||||
// Otherwise (e.g., Escape), user is in control - preserve whatever text they typed
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2187,8 +2187,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const nightly = props.version.includes('nightly');
|
||||
|
||||
const isAwaitingLoginRestart = authState === AuthState.AwaitingLoginRestart;
|
||||
const loginRestartMessage =
|
||||
settings.merged.security.auth.selectedType === AuthType.USE_VERTEX_AI
|
||||
? 'Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings.'
|
||||
: undefined;
|
||||
|
||||
const dialogsVisible =
|
||||
shouldShowIdePrompt ||
|
||||
shouldShowIdePrompt ||
|
||||
isFolderTrustDialogOpen ||
|
||||
isPolicyUpdateDialogOpen ||
|
||||
@@ -2216,6 +2221,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
!!emptyWalletRequest ||
|
||||
isSessionBrowserOpen ||
|
||||
authState === AuthState.AwaitingApiKeyInput ||
|
||||
isAwaitingLoginRestart ||
|
||||
!!newAgents;
|
||||
|
||||
const hasPendingToolConfirmation = useMemo(
|
||||
@@ -2449,6 +2455,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
accountSuspensionInfo,
|
||||
isAuthDialogOpen,
|
||||
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
|
||||
isAwaitingLoginRestart,
|
||||
loginRestartMessage,
|
||||
apiKeyDefaultValue,
|
||||
editorError,
|
||||
isEditorDialogOpen,
|
||||
@@ -2654,6 +2662,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
customDialog,
|
||||
apiKeyDefaultValue,
|
||||
authState,
|
||||
isAwaitingLoginRestart,
|
||||
loginRestartMessage,
|
||||
transientMessage,
|
||||
bannerData,
|
||||
bannerVisible,
|
||||
@@ -2728,6 +2738,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
setAuthContext,
|
||||
dismissLoginRestart: () => {
|
||||
setAuthContext({});
|
||||
setAuthState(AuthState.Updating);
|
||||
},
|
||||
onHintInput: () => {},
|
||||
onHintBackspace: () => {},
|
||||
onHintClear: () => {},
|
||||
@@ -2834,18 +2848,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
if (authState === AuthState.AwaitingGoogleLoginRestart) {
|
||||
return (
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={() => {
|
||||
setAuthContext({});
|
||||
setAuthState(AuthState.Updating);
|
||||
}}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<QuotaContext.Provider value={quotaState}>
|
||||
|
||||
@@ -243,6 +243,32 @@ describe('AuthDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await handleAuthSelect(AuthType.USE_VERTEX_AI);
|
||||
|
||||
expect(props.setAuthContext).toHaveBeenCalledWith({
|
||||
requiresRestart: true,
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', '');
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
await handleAuthSelect(AuthType.USE_VERTEX_AI);
|
||||
|
||||
expect(props.setAuthContext).toHaveBeenCalledWith({});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sets auth context with empty object for other auth types', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
|
||||
@@ -119,7 +119,12 @@ export function AuthDialog({
|
||||
return;
|
||||
}
|
||||
if (authType) {
|
||||
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
|
||||
const needsRestart =
|
||||
authType === AuthType.LOGIN_WITH_GOOGLE ||
|
||||
(authType === AuthType.USE_VERTEX_AI &&
|
||||
process.env['CLOUD_SHELL'] === 'true');
|
||||
|
||||
if (needsRestart) {
|
||||
setAuthContext({ requiresRestart: true });
|
||||
} else {
|
||||
setAuthContext({});
|
||||
|
||||
+16
-13
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
|
||||
import { LoginRestartDialog } from './LoginRestartDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ vi.mock('../../utils/cleanup.js', () => ({
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||
|
||||
describe('LoginWithGoogleRestartDialog', () => {
|
||||
describe('LoginRestartDialog', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -44,11 +44,20 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
_resetRelaunchStateForTesting();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
it('renders correctly with default message', async () => {
|
||||
const { lastFrame, unmount } = await render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
<LoginRestartDialog onDismiss={onDismiss} config={mockConfig} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly with custom message', async () => {
|
||||
const { lastFrame, unmount } = await render(
|
||||
<LoginRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
message="Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings."
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
@@ -57,10 +66,7 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
|
||||
it('calls onDismiss when escape is pressed', async () => {
|
||||
const { unmount } = await render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
<LoginRestartDialog onDismiss={onDismiss} config={mockConfig} />,
|
||||
);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
@@ -82,10 +88,7 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { unmount } = await render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
<LoginRestartDialog onDismiss={onDismiss} config={mockConfig} />,
|
||||
);
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
+16
-8
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -10,15 +10,17 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
|
||||
interface LoginWithGoogleRestartDialogProps {
|
||||
interface LoginRestartDialogProps {
|
||||
onDismiss: () => void;
|
||||
config: Config;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LoginWithGoogleRestartDialog = ({
|
||||
export const LoginRestartDialog = ({
|
||||
onDismiss,
|
||||
config,
|
||||
}: LoginWithGoogleRestartDialogProps) => {
|
||||
message,
|
||||
}: LoginRestartDialogProps) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
@@ -44,14 +46,20 @@ export const LoginWithGoogleRestartDialog = ({
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const message =
|
||||
const displayMessage =
|
||||
message ??
|
||||
"You've successfully signed in with Google. Gemini CLI needs to be restarted.";
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor={theme.status.warning} paddingX={1}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text color={theme.status.warning}>{displayMessage}</Text>
|
||||
<Text color={theme.status.warning}>
|
||||
{message} Press R to restart, or Esc to choose a different
|
||||
authentication method.
|
||||
Press R to restart, or Esc to choose a different authentication method.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LoginRestartDialog > renders correctly with custom message 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings. │
|
||||
│ Press R to restart, or Esc to choose a different authentication method. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`LoginRestartDialog > renders correctly with default message 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ You've successfully signed in with Google. Gemini CLI needs to be restarted. │
|
||||
│ Press R to restart, or Esc to choose a different authentication method. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -1,9 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ You've successfully signed in with Google. Gemini CLI needs to be restarted. Press R to restart, │
|
||||
│ or Esc to choose a different authentication method. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`commandsCommand > list > should list .toml files from available sources 1`] = `
|
||||
"### User Commands (/mock/user/commands)
|
||||
- user1.toml
|
||||
### Project Commands (/mock/project/commands)
|
||||
- proj1.toml
|
||||
### Extension: ext1 Commands (/mock/ext1/commands)
|
||||
- ext1.toml
|
||||
|
||||
_Note: MCP prompts are dynamically loaded from configured MCP servers._"
|
||||
`;
|
||||
@@ -110,7 +110,15 @@ describe('agentsCommand', () => {
|
||||
});
|
||||
|
||||
it('should reload the agent registry when reload subcommand is called', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const reloadSpy = vi.fn().mockResolvedValue({
|
||||
totalLoaded: 3,
|
||||
localCount: 2,
|
||||
remoteCount: 1,
|
||||
newAgents: ['new-agent'],
|
||||
updatedAgents: ['updated-agent'],
|
||||
deletedAgents: ['deleted-agent'],
|
||||
errors: [],
|
||||
});
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
@@ -120,7 +128,10 @@ describe('agentsCommand', () => {
|
||||
);
|
||||
expect(reloadCommand).toBeDefined();
|
||||
|
||||
const result = await reloadCommand!.action!(mockContext, '');
|
||||
const result = (await reloadCommand!.action!(mockContext, '')) as {
|
||||
type: 'message';
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
@@ -132,8 +143,42 @@ describe('agentsCommand', () => {
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content: expect.stringContaining('Agents reloaded successfully:'),
|
||||
});
|
||||
expect(result.content).toContain('- Total: 3 (2 local, 1 remote)');
|
||||
expect(result.content).toContain('- New: new-agent');
|
||||
expect(result.content).toContain('- Updated: updated-agent');
|
||||
expect(result.content).toContain('- Deleted: deleted-agent');
|
||||
expect(result.content).toContain(
|
||||
'Run /agents list to see all available agents.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should show "reloaded with errors" if errors occurred during reload', async () => {
|
||||
const reloadSpy = vi.fn().mockResolvedValue({
|
||||
totalLoaded: 1,
|
||||
localCount: 1,
|
||||
remoteCount: 0,
|
||||
newAgents: [],
|
||||
updatedAgents: [],
|
||||
deletedAgents: [],
|
||||
errors: ['Some error'],
|
||||
});
|
||||
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
|
||||
reload: reloadSpy,
|
||||
});
|
||||
|
||||
const reloadCommand = agentsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'reload',
|
||||
);
|
||||
|
||||
const result = (await reloadCommand!.action!(mockContext, '')) as {
|
||||
type: 'message';
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(result.content).toContain('Agents reloaded with errors:');
|
||||
expect(result.content).toContain('- Errors: 1 encountered during reload');
|
||||
});
|
||||
|
||||
it('should show an error if agent registry is not available during reload', async () => {
|
||||
|
||||
@@ -346,12 +346,33 @@ const agentsReloadCommand: SlashCommand = {
|
||||
text: 'Reloading agent registry...',
|
||||
});
|
||||
|
||||
await agentRegistry.reload();
|
||||
const summary = await agentRegistry.reload();
|
||||
|
||||
let content =
|
||||
summary.errors.length > 0
|
||||
? 'Agents reloaded with errors:'
|
||||
: 'Agents reloaded successfully:';
|
||||
content += `\n- Total: ${summary.totalLoaded} (${summary.localCount} local, ${summary.remoteCount} remote)`;
|
||||
|
||||
if (summary.newAgents.length > 0) {
|
||||
content += `\n- New: ${summary.newAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.updatedAgents.length > 0) {
|
||||
content += `\n- Updated: ${summary.updatedAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.deletedAgents.length > 0) {
|
||||
content += `\n- Deleted: ${summary.deletedAgents.join(', ')}`;
|
||||
}
|
||||
if (summary.errors.length > 0) {
|
||||
content += `\n- Errors: ${summary.errors.length} encountered during reload`;
|
||||
}
|
||||
|
||||
content += '\n\nRun /agents list to see all available agents.';
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Agents reloaded successfully',
|
||||
content,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,10 +12,33 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
|
||||
import { getVersion, type Config } from '@google/gemini-cli-core';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
|
||||
const { memoryUsageMock } = vi.hoisted(() => ({
|
||||
memoryUsageMock: vi.fn(() => ({
|
||||
rss: 0,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('open');
|
||||
vi.mock('../utils/formatters.js');
|
||||
vi.mock('../utils/memorySnapshot.js', () => ({
|
||||
captureHeapSnapshot: vi.fn(),
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
|
||||
}));
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
stat: vi.fn().mockResolvedValue({ size: 4096 }),
|
||||
};
|
||||
});
|
||||
vi.mock('../utils/historyExportUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/historyExportUtils.js')>();
|
||||
@@ -53,7 +76,7 @@ vi.mock('node:process', () => ({
|
||||
version: 'v20.0.0',
|
||||
// Keep other necessary process properties if needed by other parts of the code
|
||||
env: process.env,
|
||||
memoryUsage: () => ({ rss: 0 }),
|
||||
memoryUsage: memoryUsageMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -69,6 +92,13 @@ describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatBytes).mockReturnValue('100 MB');
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 0,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.stubEnv('SANDBOX', 'gemini-test');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
@@ -218,4 +248,97 @@ describe('bugCommand', () => {
|
||||
|
||||
expect(open).toHaveBeenCalledWith(expectedUrl);
|
||||
});
|
||||
|
||||
const buildHighMemoryContext = (tempDir: string | undefined) =>
|
||||
createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
getModel: () => 'gemini-pro',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeMode: () => false,
|
||||
getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }),
|
||||
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
} as unknown as Config,
|
||||
geminiClient: { getChat: () => ({ getHistory: () => [] }) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('captures a heap snapshot AFTER opening the bug URL when RSS exceeds 2 GB', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 3 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
|
||||
|
||||
const tempDir = path.join('/tmp', 'gemini-test');
|
||||
const context = buildHighMemoryContext(tempDir);
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(context, 'A memory bug');
|
||||
|
||||
const now = new Date('2024-01-01T00:00:00Z').getTime();
|
||||
const expectedSnapshotPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${now}.heapsnapshot`,
|
||||
);
|
||||
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedSnapshotPath);
|
||||
|
||||
const addItem = vi.mocked(context.ui.addItem);
|
||||
const callOrder = addItem.mock.invocationCallOrder;
|
||||
const openOrder = vi.mocked(open).mock.invocationCallOrder[0];
|
||||
// The URL message must precede the "capturing" message so the user sees
|
||||
// the URL before the 20+ second snapshot starts.
|
||||
expect(callOrder[0]).toBeLessThan(openOrder);
|
||||
expect(callOrder[1]).toBeGreaterThan(openOrder);
|
||||
expect(addItem.mock.calls[1][0].text).toContain('High memory usage');
|
||||
expect(addItem.mock.calls[2][0].text).toContain('Heap snapshot saved');
|
||||
expect(addItem.mock.calls[2][0].text).toContain(expectedSnapshotPath);
|
||||
expect(addItem.mock.calls[2][0].type).toBe(MessageType.INFO);
|
||||
});
|
||||
|
||||
it('skips auto-capture when RSS is below the 2 GB threshold', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 1 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
const context = buildHighMemoryContext('/tmp/gemini-test');
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await bugCommand.action(context, 'A light bug');
|
||||
|
||||
expect(captureHeapSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports an error if the auto-capture fails but does not throw', async () => {
|
||||
memoryUsageMock.mockReturnValue({
|
||||
rss: 3 * 1024 * 1024 * 1024,
|
||||
heapTotal: 0,
|
||||
heapUsed: 0,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
});
|
||||
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
|
||||
new Error('inspector failure'),
|
||||
);
|
||||
const context = buildHighMemoryContext('/tmp/gemini-test');
|
||||
|
||||
if (!bugCommand.action) throw new Error('Action is not defined');
|
||||
await expect(
|
||||
bugCommand.action(context, 'A memory bug'),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const addItem = vi.mocked(context.ui.addItem).mock.calls;
|
||||
const lastCall = addItem[addItem.length - 1][0];
|
||||
expect(lastCall.type).toBe(MessageType.ERROR);
|
||||
expect(lastCall.text).toContain('inspector failure');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
|
||||
import {
|
||||
captureHeapSnapshot,
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
|
||||
} from '../utils/memorySnapshot.js';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
@@ -129,6 +134,54 @@ export const bugCommand: SlashCommand = {
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
const rss = process.memoryUsage().rss;
|
||||
const tempDir = config?.storage?.getProjectTempDir();
|
||||
if (rss >= MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES && tempDir) {
|
||||
const snapshotPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${Date.now()}.heapsnapshot`,
|
||||
);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `High memory usage detected (${formatBytes(rss)}). Capturing V8 heap snapshot to ${snapshotPath}.\nThis can take 20+ seconds and the CLI may be temporarily unresponsive; please do not exit.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
await captureHeapSnapshot(snapshotPath);
|
||||
const durationMs = Date.now() - startedAt;
|
||||
let sizeText = '';
|
||||
try {
|
||||
const { size } = await stat(snapshotPath);
|
||||
sizeText = ` (${formatBytes(size)})`;
|
||||
} catch {
|
||||
// Size reporting is best-effort; the snapshot itself was captured successfully.
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${snapshotPath}\n\nConsider attaching it to your bug report only if it does not contain sensitive information.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
debugLogger.error(
|
||||
`Failed to capture heap snapshot for bug report: ${errorMessage}`,
|
||||
);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to capture heap snapshot: ${errorMessage}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { bugMemoryCommand } from './bugMemoryCommand.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../utils/memorySnapshot.js', () => ({
|
||||
captureHeapSnapshot: vi.fn(),
|
||||
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES: 2 * 1024 * 1024 * 1024,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
stat: vi.fn().mockResolvedValue({ size: 1234 }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
debugLogger: {
|
||||
error: vi.fn(),
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function makeContextWithTempDir(tempDir: string | undefined) {
|
||||
return createMockCommandContext({
|
||||
services: {
|
||||
agentContext: {
|
||||
config: {
|
||||
storage: tempDir ? { getProjectTempDir: () => tempDir } : undefined,
|
||||
} as unknown as Config,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('bugMemoryCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('declares itself as a non-auto-executing built-in command', () => {
|
||||
expect(bugMemoryCommand.name).toBe('bug-memory');
|
||||
expect(bugMemoryCommand.autoExecute).toBe(false);
|
||||
expect(bugMemoryCommand.description).toBeTruthy();
|
||||
});
|
||||
|
||||
it('captures a heap snapshot and reports the file path', async () => {
|
||||
const tempDir = path.join('/tmp', 'gemini-test');
|
||||
const context = makeContextWithTempDir(tempDir);
|
||||
vi.mocked(captureHeapSnapshot).mockResolvedValueOnce(undefined);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
const expectedPath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${new Date('2024-01-01T00:00:00Z').getTime()}.heapsnapshot`,
|
||||
);
|
||||
expect(captureHeapSnapshot).toHaveBeenCalledWith(expectedPath);
|
||||
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
expect(addItemCalls).toHaveLength(2);
|
||||
expect(addItemCalls[0][0]).toMatchObject({ type: MessageType.INFO });
|
||||
expect(addItemCalls[0][0].text).toContain(expectedPath);
|
||||
expect(addItemCalls[1][0]).toMatchObject({ type: MessageType.INFO });
|
||||
expect(addItemCalls[1][0].text).toContain('Heap snapshot saved');
|
||||
expect(addItemCalls[1][0].text).toContain(expectedPath);
|
||||
});
|
||||
|
||||
it('surfaces an error if capture fails', async () => {
|
||||
const context = makeContextWithTempDir('/tmp/gemini-test');
|
||||
vi.mocked(captureHeapSnapshot).mockRejectedValueOnce(
|
||||
new Error('inspector disconnected'),
|
||||
);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
const lastCall = addItemCalls[addItemCalls.length - 1][0];
|
||||
expect(lastCall.type).toBe(MessageType.ERROR);
|
||||
expect(lastCall.text).toContain('inspector disconnected');
|
||||
});
|
||||
|
||||
it('emits an error when no project temp directory is available', async () => {
|
||||
const context = makeContextWithTempDir(undefined);
|
||||
|
||||
if (!bugMemoryCommand.action) throw new Error('Action missing');
|
||||
await bugMemoryCommand.action(context, '');
|
||||
|
||||
expect(captureHeapSnapshot).not.toHaveBeenCalled();
|
||||
const addItemCalls = vi.mocked(context.ui.addItem).mock.calls;
|
||||
expect(addItemCalls).toHaveLength(1);
|
||||
expect(addItemCalls[0][0].type).toBe(MessageType.ERROR);
|
||||
expect(addItemCalls[0][0].text).toContain('temp directory');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { formatBytes } from '../utils/formatters.js';
|
||||
import { captureHeapSnapshot } from '../utils/memorySnapshot.js';
|
||||
|
||||
export const bugMemoryCommand: SlashCommand = {
|
||||
name: 'bug-memory',
|
||||
description: 'Capture a V8 heap snapshot to disk to attach to a bug report',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
const tempDir =
|
||||
context.services.agentContext?.config?.storage?.getProjectTempDir();
|
||||
if (!tempDir) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Cannot capture heap snapshot: project temp directory is unavailable.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(
|
||||
tempDir,
|
||||
`bug-memory-${Date.now()}.heapsnapshot`,
|
||||
);
|
||||
const rss = process.memoryUsage().rss;
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Capturing V8 heap snapshot (current RSS: ${formatBytes(rss)}).\nThis can take 20+ seconds and the CLI may be temporarily unresponsive — please do not exit.\nDestination: ${filePath}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await captureHeapSnapshot(filePath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
debugLogger.error(`Failed to capture heap snapshot: ${message}`);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to capture heap snapshot: ${message}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startedAt;
|
||||
let sizeText = '';
|
||||
try {
|
||||
const { size } = await stat(filePath);
|
||||
sizeText = ` (${formatBytes(size)})`;
|
||||
} catch {
|
||||
// Size reporting is best-effort; the snapshot itself was captured successfully.
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Heap snapshot saved${sizeText} in ${durationMs}ms:\n${filePath}\n\nLoad it in Chrome DevTools → Memory → "Load" to analyze. Attach it to your bug report only if it does not contain sensitive information.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -4,11 +4,32 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Storage, type Config } from '@google/gemini-cli-core';
|
||||
import { commandsCommand } from './commandsCommand.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
|
||||
vi.mock('../../services/FileCommandLoader.js');
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@google/gemini-cli-core')
|
||||
>('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
Storage: class extends actual.Storage {
|
||||
static override getUserCommandsDir() {
|
||||
return '/mock/user/commands';
|
||||
}
|
||||
override getProjectCommandsDir() {
|
||||
return '/mock/project/commands';
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('commandsCommand', () => {
|
||||
let context: CommandContext;
|
||||
@@ -18,10 +39,27 @@ describe('commandsCommand', () => {
|
||||
context = createMockCommandContext({
|
||||
ui: {
|
||||
reloadCommands: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
},
|
||||
services: {
|
||||
agentContext: {
|
||||
getProjectRoot: vi.fn().mockReturnValue('/mock/project'),
|
||||
getFolderTrust: vi.fn().mockReturnValue(false),
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
getExtensions: vi.fn().mockReturnValue([
|
||||
{ name: 'ext1', path: '/mock/ext1', isActive: true },
|
||||
{ name: 'ext2', path: '/mock/ext2', isActive: false },
|
||||
]),
|
||||
storage: new Storage('/mock/project'),
|
||||
} as unknown as Config,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('default action', () => {
|
||||
it('should return an info message prompting subcommand usage', async () => {
|
||||
const result = await commandsCommand.action!(context, '');
|
||||
@@ -30,7 +68,70 @@ describe('commandsCommand', () => {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Use "/commands reload" to reload custom command definitions from .toml files.',
|
||||
'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should list .toml files from available sources', async () => {
|
||||
vi.mocked(
|
||||
FileCommandLoader.prototype.listAvailableFiles,
|
||||
).mockResolvedValue([
|
||||
{
|
||||
displayName: 'User',
|
||||
path: '/mock/user/commands',
|
||||
files: ['user1.toml'],
|
||||
},
|
||||
{
|
||||
displayName: 'Project',
|
||||
path: '/mock/project/commands',
|
||||
files: ['proj1.toml'],
|
||||
},
|
||||
{
|
||||
displayName: 'Extension: ext1',
|
||||
path: '/mock/ext1/commands',
|
||||
files: ['ext1.toml'],
|
||||
},
|
||||
]);
|
||||
|
||||
const listCmd = commandsCommand.subCommands!.find(
|
||||
(s) => s.name === 'list',
|
||||
)!;
|
||||
|
||||
await listCmd.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.INFO,
|
||||
text: expect.any(String),
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
// Snapshot the text content
|
||||
const addItemCall = vi.mocked(context.ui.addItem).mock.calls[0][0];
|
||||
|
||||
expect((addItemCall as { text: string }).text).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should show "No custom command files found" message if no .toml files exist', async () => {
|
||||
vi.mocked(
|
||||
FileCommandLoader.prototype.listAvailableFiles,
|
||||
).mockResolvedValue([]);
|
||||
|
||||
const listCmd = commandsCommand.subCommands!.find(
|
||||
(s) => s.name === 'list',
|
||||
)!;
|
||||
|
||||
const result = await listCmd.action!(context, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining(
|
||||
'No custom command files (.toml) found.',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
@@ -20,7 +21,7 @@ import {
|
||||
* Action for the default `/commands` invocation.
|
||||
* Displays a message prompting the user to use a subcommand.
|
||||
*/
|
||||
async function listAction(
|
||||
async function defaultAction(
|
||||
_context: CommandContext,
|
||||
_args: string,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
@@ -28,10 +29,64 @@ async function listAction(
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'Use "/commands reload" to reload custom command definitions from .toml files.',
|
||||
'Use "/commands list" to view available .toml files, or "/commands reload" to reload custom command definitions.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for `/commands list`.
|
||||
* Lists available .toml command files from user, project, and extension directories.
|
||||
*/
|
||||
async function listSubcommandAction(
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> {
|
||||
try {
|
||||
const config = context.services.agentContext?.config ?? null;
|
||||
const loader = new FileCommandLoader(config);
|
||||
const groups = await loader.listAvailableFiles();
|
||||
|
||||
const results: string[] = [];
|
||||
for (const group of groups) {
|
||||
results.push(`### ${group.displayName} Commands (${group.path})`);
|
||||
if (group.error) {
|
||||
results.push(`- (Error reading directory: ${group.error})`);
|
||||
} else {
|
||||
group.files.forEach((file) => results.push(`- ${file}`));
|
||||
}
|
||||
}
|
||||
|
||||
results.push(
|
||||
'\n_Note: MCP prompts are dynamically loaded from configured MCP servers._',
|
||||
);
|
||||
|
||||
if (results.length === 1) {
|
||||
// Only the note is present
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'No custom command files (.toml) found.\n\n_Note: MCP prompts are dynamically loaded from configured MCP servers._',
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: results.join('\n'),
|
||||
} as HistoryItemInfo,
|
||||
Date.now(),
|
||||
);
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to list commands: ${error instanceof Error ? error.message : String(error)}`,
|
||||
} as HistoryItemError,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for `/commands reload`.
|
||||
* Triggers a full re-discovery and reload of all slash commands, including
|
||||
@@ -63,10 +118,18 @@ async function reloadAction(
|
||||
|
||||
export const commandsCommand: SlashCommand = {
|
||||
name: 'commands',
|
||||
description: 'Manage custom slash commands. Usage: /commands [reload]',
|
||||
description: 'Manage custom slash commands. Usage: /commands [list|reload]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'list',
|
||||
description:
|
||||
'List available custom command .toml files. Usage: /commands list',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: listSubcommandAction,
|
||||
},
|
||||
{
|
||||
name: 'reload',
|
||||
altNames: ['refresh'],
|
||||
@@ -77,5 +140,5 @@ export const commandsCommand: SlashCommand = {
|
||||
action: reloadAction,
|
||||
},
|
||||
],
|
||||
action: listAction,
|
||||
action: defaultAction,
|
||||
};
|
||||
|
||||
@@ -782,6 +782,13 @@ describe('extensionsCommand', () => {
|
||||
expect(mockUninstallExtension).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should expose "delete" as an alias', () => {
|
||||
const uninstallCmd = extensionsCommand(true).subCommands?.find(
|
||||
(cmd) => cmd.name === 'uninstall',
|
||||
);
|
||||
expect(uninstallCmd?.altNames).toContain('delete');
|
||||
});
|
||||
|
||||
it('should call uninstallExtension and show success message', async () => {
|
||||
const extensionName = 'test-extension';
|
||||
await uninstallAction!(mockContext, extensionName);
|
||||
|
||||
@@ -838,6 +838,7 @@ const linkCommand: SlashCommand = {
|
||||
|
||||
const uninstallCommand: SlashCommand = {
|
||||
name: 'uninstall',
|
||||
altNames: ['delete'],
|
||||
description: 'Uninstall an extension',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type SlashCommand,
|
||||
type SlashCommandActionReturn,
|
||||
} from './types.js';
|
||||
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
|
||||
import { InboxDialog } from '../components/InboxDialog.js';
|
||||
|
||||
export const memoryCommand: SlashCommand = {
|
||||
name: 'memory',
|
||||
@@ -156,13 +156,16 @@ export const memoryCommand: SlashCommand = {
|
||||
|
||||
return {
|
||||
type: 'custom_dialog',
|
||||
component: React.createElement(SkillInboxDialog, {
|
||||
component: React.createElement(InboxDialog, {
|
||||
config,
|
||||
onClose: () => context.ui.removeComponent(),
|
||||
onReloadSkills: async () => {
|
||||
await config.reloadSkills();
|
||||
context.ui.reloadCommands();
|
||||
},
|
||||
onReloadMemory: async () => {
|
||||
await refreshMemory(config);
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { linkSkill } from '../../utils/skillUtils.js';
|
||||
import { requestConsentInteractive } from '../../config/extensions/consent.js';
|
||||
|
||||
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -253,6 +254,36 @@ describe('skillsCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass a cleanup callback for interactive workspace consent', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
)!;
|
||||
context.ui.setConfirmationRequest = vi.fn();
|
||||
vi.mocked(linkSkill).mockImplementation(
|
||||
async (_sourcePath, _scope, _addItem, requestConsent) => {
|
||||
expect(requestConsent).toBeDefined();
|
||||
await requestConsent!(
|
||||
[{ name: 'test-skill', location: '/path' } as SkillDefinition],
|
||||
'/workspace/.gemini/skills',
|
||||
);
|
||||
return [{ name: 'test-skill', location: '/path' }];
|
||||
},
|
||||
);
|
||||
|
||||
await linkCmd.action!(context, '/some/path --scope workspace');
|
||||
|
||||
const requestConsentCall = vi
|
||||
.mocked(requestConsentInteractive)
|
||||
.mock.calls.at(-1);
|
||||
expect(requestConsentCall?.[1]).toEqual(expect.any(Function));
|
||||
|
||||
const clearConfirmationRequest = requestConsentCall?.[2];
|
||||
expect(clearConfirmationRequest).toBeTypeOf('function');
|
||||
|
||||
clearConfirmationRequest?.();
|
||||
expect(context.ui.setConfirmationRequest).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('should show error if link fails', async () => {
|
||||
const linkCmd = skillsCommand.subCommands!.find(
|
||||
(s) => s.name === 'link',
|
||||
|
||||
@@ -118,6 +118,7 @@ async function linkAction(
|
||||
return requestConsentInteractive(
|
||||
consentString,
|
||||
context.ui.setConfirmationRequest.bind(context.ui),
|
||||
() => context.ui.setConfirmationRequest(null),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user