Compare commits

...

16 Commits

Author SHA1 Message Date
cynthialong0-0 cf86f34576 Update evals/grep_search_functionality.eval.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-05 05:30:46 -07:00
cynthialong0-0 518832c586 Merge branch 'main' into feat/search-hidden-dir 2026-05-05 05:05:44 -07:00
cynthialong0-0 60b79d676a feat(core): enable search hidden dir 2026-05-05 08:26:15 +00:00
Keith Schaab 1d72a120fb fix(a2a-server): resolve tool approval race condition and improve status reporting (#26479)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-05 02:47:26 +00:00
Tirth Naik 8f0edcd64f fix(cli): use os.homedir() for home directory warning check (#25890) 2026-05-04 23:24:49 +00:00
Christian Gunderman 04e875c5c8 fix(ci): respect exempt labels when closing stale items (#26475) 2026-05-04 23:00:14 +00:00
gemini-cli-robot a79da4f3a9 Robust Scale-Safe Lifecycle Consolidation (#26355)
Co-authored-by: gemini-cli[bot] <gemini-cli[bot]@users.noreply.github.com>
Co-authored-by: Christian Gunderman <gundermanc@google.com>
2026-05-04 22:07:47 +00:00
Sandy Tao 56809d7069 fix(cli): make SkillInboxDialog fit and scroll in alternate buffer (#26455) 2026-05-04 21:54:13 +00:00
Anjaligarhwal 5dfbb739e5 feat(cli): add /bug-memory command and auto-capture heap snapshot in /bug (#25639) 2026-05-04 21:17:36 +00:00
Christian Gunderman f87072f4e3 feat(bot): add actions spend metric script (#26463) 2026-05-04 21:01:39 +00:00
Adib234 6a3175e973 fix(core): properly format markdown in AskUser tool by unescaping newlines (#26349)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-04 20:59:11 +00:00
Aishanee Shah 4d1ca92a19 fix(core): filter unsupported multimodal types from tool responses (#26352) 2026-05-04 20:31:20 +00:00
Horizon_Architect_07 b6fc583b0c Fix: make Dockerfile self-contained with multi-stage build (#24277)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-05-04 19:51:06 +00:00
Coco Sheng 0d6bd29752 feat(cli): improve /agents refresh logging (#26442) 2026-05-04 19:40:48 +00:00
ANDI FAUZAN HEDIANTORO 78877942ec docs(sdk): add JSDoc to all exported interfaces and types (#26277) 2026-05-04 19:32:47 +00:00
Coco Sheng 493b555646 feat: add ignoreLocalEnv setting and --ignore-env flag (#2493) (#26445) 2026-05-04 19:14:33 +00:00
56 changed files with 3694 additions and 1218 deletions
+17
View File
@@ -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
@@ -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,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'
});
}
}
}
}
-33
View File
@@ -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
});
-44
View File
@@ -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
View File
@@ -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"]
+1
View File
@@ -158,6 +158,7 @@ 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
+6
View File
@@ -1752,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`
+20
View File
@@ -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`,
});
},
});
});
@@ -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
+203
View File
@@ -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);
});
});
});
+31 -9
View File
@@ -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,
+79 -37
View File
@@ -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 () => {
@@ -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();
});
});
+14 -3
View File
@@ -500,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
@@ -512,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) {
@@ -595,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
@@ -612,6 +617,12 @@ 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') {
const selectedAuthType = settings.security?.auth?.selectedType;
+10
View File
@@ -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',
@@ -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,
@@ -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 () => {
+23 -2
View File
@@ -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,
};
},
};
+124 -1
View File
@@ -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(),
);
},
};
@@ -26,8 +26,13 @@ import {
} from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { InboxDialog } from './InboxDialog.js';
const altBufferSettings = createMockSettings({
ui: { useAlternateBuffer: true },
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -835,5 +840,332 @@ describe('InboxDialog', () => {
consoleErrorSpy.mockRestore();
unmount();
});
const tallPatch: InboxPatch = {
fileName: 'tall.patch',
name: 'tall-patch',
entries: [
{
targetPath: '/repo/.gemini/skills/docs-writer/SKILL.md',
diffContent: [
'--- /repo/.gemini/skills/docs-writer/SKILL.md',
'+++ /repo/.gemini/skills/docs-writer/SKILL.md',
'@@ -1,4 +1,8 @@',
' line1',
' line2',
'+added-1',
'+added-2',
'+added-3',
'+added-4',
' line3',
' line4',
].join('\n'),
},
],
};
it('alt-buffer: renders a bounded ScrollableList viewport for tall patches', async () => {
// Alt-buffer mode has no terminal scrollback, so the dialog must
// scroll inside itself. ScrollableList renders a `█` thumb when
// content exceeds viewport height — the regression signal that the
// diff is bounded and off-screen content is reachable via PgUp/PgDn.
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([tallPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{
settings: altBufferSettings,
uiState: { terminalHeight: 18 },
},
),
);
await waitFor(() => {
expect(lastFrame()).toContain('tall-patch');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Apply');
expect(frame).toContain('Dismiss');
expect(frame).toContain('█');
});
unmount();
});
it('alt-buffer: surfaces PgUp/PgDn in the patch-preview footer', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([inboxPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ settings: altBufferSettings },
),
);
await waitFor(() => {
expect(lastFrame()).toContain('update-docs');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame()).toContain('PgUp/PgDn to scroll');
});
unmount();
});
it('non-alt-buffer: clips the diff via DiffRenderer with a "lines hidden" hint', async () => {
// Non-alt-buffer mode uses the codebase's standard bounded
// DiffRenderer + ShowMoreLines + Ctrl+O pattern (matches
// FolderTrustDialog/ThemeDialog). MaxSizedBox emits a
// "... first/last N line(s) hidden ..." hint when it clips, which
// is the regression signal that the diff is bounded.
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([tallPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ uiState: { terminalHeight: 18, constrainHeight: true } },
),
);
await waitFor(() => {
expect(lastFrame()).toContain('tall-patch');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame() ?? '').toMatch(/lines? hidden/);
});
unmount();
});
it('non-alt-buffer: surfaces Ctrl+O inline (not in the footer) when the diff overflows', async () => {
// In non-alt-buffer mode the Ctrl+O affordance is rendered inline
// by ShowMoreLines above the footer when the diff is clipped. The
// footer itself stays clean (no PgUp/PgDn or Ctrl+O text) since
// duplicating the hint there would be noisy.
mockListInboxSkills.mockResolvedValue([]);
mockListInboxPatches.mockResolvedValue([tallPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ uiState: { terminalHeight: 18, constrainHeight: true } },
),
);
await waitFor(() => {
expect(lastFrame()).toContain('tall-patch');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Ctrl+O');
expect(frame).not.toContain('PgUp/PgDn to scroll');
});
unmount();
});
});
it('renders each list row as exactly two lines even with long descriptions', async () => {
// Reproduces the production bug: with the previous renderer, long
// descriptions wrapped onto multiple lines (and the date sibling was
// interleaved into the wrap), making each item 3-5 rows tall and
// breaking the listMaxItemsToShow budget. The fix uses height={2}
// and wrap="truncate-end" on every list row.
const longDescription =
'This is an extremely long description that would absolutely wrap to ' +
'multiple lines if rendered without truncation, which used to push the ' +
'list-phase footer off the bottom of the alternate buffer in production.';
mockListInboxSkills.mockResolvedValue([
{
dirName: 'long-skill',
name: 'long-skill',
description: longDescription,
content: '---\nname: x\ndescription: y\n---\n',
},
]);
mockListInboxPatches.mockResolvedValue([]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('long-skill');
});
const frame = lastFrame() ?? '';
expect(frame).not.toContain('production');
expect(frame).toContain('extremely long description');
unmount();
});
it('keeps the list-phase footer on screen with many long-description skills', async () => {
const longDesc =
'A very long description that would wrap across multiple lines if not ' +
'truncated, which was causing the dialog body to overflow the bottom ' +
'of the alternate buffer';
const manySkills: InboxSkill[] = Array.from({ length: 8 }, (_, i) => ({
dirName: `skill-${i}`,
name: `skill-${i}`,
description: `${longDesc} (#${i})`,
content: '---\nname: x\ndescription: y\n---\n',
}));
mockListInboxSkills.mockResolvedValue(manySkills);
mockListInboxPatches.mockResolvedValue([]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ uiState: { terminalHeight: 28 } },
),
);
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Memory Inbox');
expect(frame).toContain('Esc to close');
});
unmount();
});
it('keeps the list-phase footer on screen on short terminals', async () => {
const manySkills: InboxSkill[] = Array.from({ length: 12 }, (_, i) => ({
dirName: `skill-${i}`,
name: `Skill ${i}`,
description: `Description ${i}`,
content: '---\nname: Skill\ndescription: Skill\n---\n',
}));
mockListInboxSkills.mockResolvedValue(manySkills);
mockListInboxPatches.mockResolvedValue([inboxPatch]);
mockListInboxMemoryPatches.mockResolvedValue([]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
storage: {
getProjectSkillsDir: vi.fn().mockReturnValue('/repo/.gemini/skills'),
},
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
{ uiState: { terminalHeight: 18 } },
),
);
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Memory Inbox');
expect(frame).toContain('Esc to close');
});
unmount();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Readable } from 'node:stream';
import {
captureHeapSnapshot,
MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES,
} from './memorySnapshot.js';
const { mkdirMock, pipelineMock, getHeapSnapshotMock, createWriteStreamMock } =
vi.hoisted(() => ({
mkdirMock: vi.fn(async () => undefined),
pipelineMock: vi.fn(async () => undefined),
getHeapSnapshotMock: vi.fn(),
createWriteStreamMock: vi.fn(),
}));
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return { ...actual, mkdir: mkdirMock };
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, createWriteStream: createWriteStreamMock };
});
vi.mock('node:v8', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:v8')>();
return { ...actual, getHeapSnapshot: getHeapSnapshotMock };
});
vi.mock('node:stream/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:stream/promises')>();
return { ...actual, pipeline: pipelineMock };
});
describe('captureHeapSnapshot', () => {
beforeEach(() => {
mkdirMock.mockClear();
pipelineMock.mockClear();
getHeapSnapshotMock.mockClear().mockReturnValue(Readable.from([]));
createWriteStreamMock
.mockClear()
.mockReturnValue({ write: vi.fn(), end: vi.fn() });
});
afterEach(() => {
vi.clearAllMocks();
});
it('exports the 2 GB auto-capture threshold', () => {
expect(MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES).toBe(2 * 1024 * 1024 * 1024);
});
it('creates the target directory and pipelines the V8 snapshot to disk', async () => {
const target = '/tmp/gemini-test/snapshot.heapsnapshot';
await captureHeapSnapshot(target);
expect(mkdirMock).toHaveBeenCalledWith('/tmp/gemini-test', {
recursive: true,
});
expect(getHeapSnapshotMock).toHaveBeenCalledTimes(1);
expect(createWriteStreamMock).toHaveBeenCalledWith(target);
expect(pipelineMock).toHaveBeenCalledTimes(1);
expect(pipelineMock).toHaveBeenCalledWith(
getHeapSnapshotMock.mock.results[0].value,
createWriteStreamMock.mock.results[0].value,
);
});
it('propagates pipeline failures to the caller', async () => {
pipelineMock.mockRejectedValueOnce(new Error('write failed'));
await expect(
captureHeapSnapshot('/tmp/gemini-test/fail.heapsnapshot'),
).rejects.toThrow('write failed');
});
});
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { getHeapSnapshot } from 'node:v8';
/**
* RSS threshold at which `/bug` auto-captures a heap snapshot.
*/
export const MEMORY_SNAPSHOT_AUTO_THRESHOLD_BYTES = 2 * 1024 * 1024 * 1024;
/**
* Capture a V8 heap snapshot from the current process and write it to disk.
*
* `v8.getHeapSnapshot()` returns a Readable stream whose producer is V8's
* internal snapshot generator. Piping it through `node:stream/promises`'
* `pipeline` propagates backpressure end-to-end, so even a multi-gigabyte
* heap is written without buffering the serialized snapshot in memory.
* Nothing is exposed over a debugger port.
*/
export async function captureHeapSnapshot(filePath: string): Promise<void> {
await mkdir(dirname(filePath), { recursive: true });
await pipeline(getHeapSnapshot(), createWriteStream(filePath));
}
@@ -19,11 +19,11 @@ import {
} from '@google/gemini-cli-core';
// Mock os.homedir to control the home directory in tests
vi.mock('os', async (importOriginal) => {
vi.mock('node:os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
return {
...actualOs,
homedir: vi.fn(),
homedir: vi.fn(() => actualOs.homedir()),
};
});
@@ -32,7 +32,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
homedir: () => os.homedir(),
getCompatibilityWarnings: vi.fn().mockReturnValue([]),
isHeadlessMode: vi.fn().mockReturnValue(false),
WarningPriority: {
@@ -66,6 +65,7 @@ describe('getUserStartupWarnings', () => {
afterEach(async () => {
await fs.rm(testRootDir, { recursive: true, force: true });
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -98,6 +98,54 @@ describe('getUserStartupWarnings', () => {
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when running in a subdirectory of home', async () => {
const subDir = path.join(homeDir, 'projects', 'my-app');
await fs.mkdir(subDir, { recursive: true });
const warnings = await getUserStartupWarnings({}, subDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when home directory is a symlink and running in a subdirectory', async () => {
const realHome = path.join(testRootDir, 'real-home');
await fs.mkdir(realHome, { recursive: true });
const symlinkedHome = path.join(testRootDir, 'symlinked-home');
await fs.symlink(realHome, symlinkedHome);
vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
const subDir = path.join(symlinkedHome, 'projects');
await fs.mkdir(subDir, { recursive: true });
const warnings = await getUserStartupWarnings({}, subDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should return a warning when home directory is a symlink and running in it', async () => {
const realHome = path.join(testRootDir, 'real-home2');
await fs.mkdir(realHome, { recursive: true });
const symlinkedHome = path.join(testRootDir, 'symlinked-home2');
await fs.symlink(realHome, symlinkedHome);
vi.mocked(os.homedir).mockReturnValue(symlinkedHome);
const warnings = await getUserStartupWarnings({}, symlinkedHome);
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'home-directory',
message: expect.stringContaining(
'Warning you are running Gemini CLI in your home directory',
),
priority: WarningPriority.Low,
}),
);
});
it('should not return a warning when GEMINI_CLI_HOME differs from os.homedir', async () => {
const projectDir = path.join(testRootDir, 'project');
await fs.mkdir(projectDir, { recursive: true });
vi.stubEnv('GEMINI_CLI_HOME', projectDir);
const warnings = await getUserStartupWarnings({}, projectDir);
expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined();
});
it('should not return a warning when folder trust is enabled and workspace is trusted', async () => {
vi.mocked(isFolderTrustEnabled).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue({
@@ -5,10 +5,10 @@
*/
import fs from 'node:fs/promises';
import { homedir as osHomedir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import {
homedir,
getCompatibilityWarnings,
WarningPriority,
type StartupWarning,
@@ -39,10 +39,10 @@ const homeDirectoryCheck: WarningCheck = {
try {
const [workspaceRealPath, homeRealPath] = await Promise.all([
fs.realpath(workspaceRoot),
fs.realpath(homedir()),
fs.realpath(osHomedir()),
]);
if (workspaceRealPath === homeRealPath) {
if (path.resolve(workspaceRealPath) === path.resolve(homeRealPath)) {
// If folder trust is enabled and the user trusts the home directory, don't show the warning.
if (
isFolderTrustEnabled(settings) &&
+1 -2
View File
@@ -459,7 +459,7 @@ describe('AgentRegistry', () => {
await registry.initialize();
// Verify ackService was called with the URL, not the file hash
// Verify ackService was called with the raw URL to avoid breaking changes
expect(ackService.isAcknowledged).toHaveBeenCalledWith(
expect.anything(),
'RemoteAgent',
@@ -467,7 +467,6 @@ describe('AgentRegistry', () => {
);
// Also verify that the agent's metadata was updated to use the URL as hash
// Use getDefinition because registerAgent might have been called
expect(registry.getDefinition('RemoteAgent')?.metadata?.hash).toBe(
'https://example.com/card',
);
+105 -62
View File
@@ -8,7 +8,11 @@ import * as crypto from 'node:crypto';
import { Storage } from '../config/storage.js';
import { CoreEvent, coreEvents } from '../utils/events.js';
import type { AgentOverride, Config } from '../config/config.js';
import type { AgentDefinition, LocalAgentDefinition } from './types.js';
import {
type AgentDefinition,
type LocalAgentDefinition,
type AgentReloadSummary,
} from './types.js';
import { getAgentCardLoadOptions, getRemoteAgentTargetUrl } from './types.js';
import { loadAgentsFromDirectory } from './agentLoader.js';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
@@ -80,13 +84,53 @@ export class AgentRegistry {
/**
* Clears the current registry and re-scans for agents.
*/
async reload(): Promise<void> {
async reload(): Promise<AgentReloadSummary> {
const previousAgents = new Map(this.agents);
const reloadErrors: string[] = [];
this.config.getA2AClientManager()?.clearCache();
await this.config.reloadAgents();
this.agents.clear();
this.allDefinitions.clear();
await this.loadAgents();
await this.loadAgents(reloadErrors);
const currentAgents = Array.from(this.agents.values());
const newAgents: string[] = [];
const updatedAgents: string[] = [];
const deletedAgents: string[] = [];
let localCount = 0;
let remoteCount = 0;
for (const agent of currentAgents) {
if (agent.kind === 'local') {
localCount++;
} else if (agent.kind === 'remote') {
remoteCount++;
}
const prev = previousAgents.get(agent.name);
if (!prev) {
newAgents.push(agent.name);
} else if (agent.metadata?.hash !== prev.metadata?.hash) {
updatedAgents.push(agent.name);
}
}
for (const prevName of previousAgents.keys()) {
if (!this.agents.has(prevName)) {
deletedAgents.push(prevName);
}
}
coreEvents.emitAgentsRefreshed();
return {
totalLoaded: currentAgents.length,
localCount,
remoteCount,
newAgents,
updatedAgents,
deletedAgents,
errors: reloadErrors,
};
}
/**
@@ -113,7 +157,7 @@ export class AgentRegistry {
coreEvents.off(CoreEvent.ModelChanged, this.onModelChanged);
}
private async loadAgents(): Promise<void> {
private async loadAgents(errors?: string[]): Promise<void> {
this.agents.clear();
this.allDefinitions.clear();
this.loadBuiltInAgents();
@@ -132,21 +176,20 @@ export class AgentRegistry {
debugLogger.warn(
`[AgentRegistry] Error loading user agent: ${error.message}`,
);
coreEvents.emitFeedback('error', `Agent loading error: ${error.message}`);
const msg = `Agent loading error: ${error.message}`;
errors?.push(msg);
coreEvents.emitFeedback('error', msg);
}
await Promise.allSettled(
userAgents.agents.map(async (agent) => {
try {
await this.registerAgent(agent);
this.ensureRemoteAgentHash(agent);
await this.registerAgent(agent, errors);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Error registering user agent "${agent.name}":`,
e,
);
coreEvents.emitFeedback(
'error',
`Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
);
const msg = `Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
debugLogger.warn(`[AgentRegistry] ${msg}`, e);
errors?.push(msg);
coreEvents.emitFeedback('error', msg);
}
}),
);
@@ -159,10 +202,9 @@ export class AgentRegistry {
const projectAgentsDir = this.config.storage.getProjectAgentsDir();
const projectAgents = await loadAgentsFromDirectory(projectAgentsDir);
for (const error of projectAgents.errors) {
coreEvents.emitFeedback(
'error',
`Agent loading error: ${error.message}`,
);
const msg = `Agent loading error: ${error.message}`;
errors?.push(msg);
coreEvents.emitFeedback('error', msg);
}
const ackService = this.config.getAcknowledgedAgentsService();
@@ -171,21 +213,7 @@ export class AgentRegistry {
const agentsToRegister: AgentDefinition[] = [];
for (const agent of projectAgents.agents) {
// If it's a remote agent, use the agentCardUrl as the hash.
// This allows multiple remote agents in a single file to be tracked independently.
if (agent.kind === 'remote') {
if (!agent.metadata) {
agent.metadata = {};
}
agent.metadata.hash =
agent.agentCardUrl ??
(agent.agentCardJson
? crypto
.createHash('sha256')
.update(agent.agentCardJson)
.digest('hex')
: undefined);
}
this.ensureRemoteAgentHash(agent);
if (!agent.metadata?.hash) {
agentsToRegister.push(agent);
@@ -212,16 +240,12 @@ export class AgentRegistry {
await Promise.allSettled(
agentsToRegister.map(async (agent) => {
try {
await this.registerAgent(agent);
await this.registerAgent(agent, errors);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Error registering project agent "${agent.name}":`,
e,
);
coreEvents.emitFeedback(
'error',
`Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
);
const msg = `Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
debugLogger.warn(`[AgentRegistry] ${msg}`, e);
errors?.push(msg);
coreEvents.emitFeedback('error', msg);
}
}),
);
@@ -238,16 +262,12 @@ export class AgentRegistry {
await Promise.allSettled(
extension.agents.map(async (agent) => {
try {
await this.registerAgent(agent);
await this.registerAgent(agent, errors);
} catch (e) {
debugLogger.warn(
`[AgentRegistry] Error registering extension agent "${agent.name}":`,
e,
);
coreEvents.emitFeedback(
'error',
`Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`,
);
const msg = `Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`;
debugLogger.warn(`[AgentRegistry] ${msg}`, e);
errors?.push(msg);
coreEvents.emitFeedback('error', msg);
}
}),
);
@@ -314,11 +334,12 @@ export class AgentRegistry {
*/
protected async registerAgent<TOutput extends z.ZodTypeAny>(
definition: AgentDefinition<TOutput>,
errors?: string[],
): Promise<void> {
if (definition.kind === 'local') {
this.registerLocalAgent(definition);
} else if (definition.kind === 'remote') {
await this.registerRemoteAgent(definition);
await this.registerRemoteAgent(definition, errors);
}
}
@@ -416,6 +437,7 @@ export class AgentRegistry {
*/
protected async registerRemoteAgent<TOutput extends z.ZodTypeAny>(
definition: AgentDefinition<TOutput>,
errors?: string[],
): Promise<void> {
if (definition.kind !== 'remote') {
return;
@@ -544,17 +566,14 @@ export class AgentRegistry {
this.addAgentPolicy(definition);
} catch (e) {
// Surface structured, user-friendly error messages for known failure modes.
let msg: string;
if (e instanceof A2AAgentError) {
coreEvents.emitFeedback(
'error',
`[${definition.name}] ${e.userMessage}`,
);
msg = `[${definition.name}] ${e.userMessage}`;
} else {
coreEvents.emitFeedback(
'error',
`[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`,
);
msg = `[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`;
}
errors?.push(msg);
coreEvents.emitFeedback('error', msg);
debugLogger.warn(
`[AgentRegistry] Error loading A2A agent "${definition.name}":`,
e,
@@ -704,4 +723,28 @@ export class AgentRegistry {
getDiscoveredDefinition(name: string): AgentDefinition | undefined {
return this.allDefinitions.get(name);
}
/**
* Ensures that remote agents have a content-based hash for trust verification and change detection.
*/
private ensureRemoteAgentHash(agent: AgentDefinition): void {
if (agent.kind !== 'remote') {
return;
}
if (!agent.metadata) {
agent.metadata = {};
}
// To avoid a breaking change for existing users, we continue to use
// the raw URL as the hash for URL-based remote agents.
if (agent.agentCardUrl) {
agent.metadata.hash = agent.agentCardUrl;
} else if (agent.agentCardJson) {
agent.metadata.hash = crypto
.createHash('sha256')
.update(agent.agentCardJson)
.digest('hex');
}
}
}
+13
View File
@@ -369,3 +369,16 @@ export interface RunConfig {
*/
maxTurns?: number;
}
/**
* Summary of an agent reload operation.
*/
export interface AgentReloadSummary {
totalLoaded: number;
localCount: number;
remoteCount: number;
newAgents: string[];
updatedAgents: string[];
deletedAgents: string[];
errors: string[];
}
+148
View File
@@ -38,6 +38,7 @@ import * as policyHelpers from '../availability/policyHelpers.js';
import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils.js';
import type { HookSystem } from '../hooks/hookSystem.js';
import { LlmRole } from '../telemetry/types.js';
import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js';
// Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>();
@@ -2575,6 +2576,153 @@ describe('GeminiChat', () => {
});
});
describe('automated binary injection', () => {
it('should expand history with synthetic turns when __binary_injection__ is detected', async () => {
const audioParts = [
{
functionResponse: {
id: 'call-123',
name: 'read_file',
response: {
output: 'Success',
[BINARY_INJECTION_KEY]: [
{ inlineData: { mimeType: 'audio/mpeg', data: 'base64' } },
],
},
},
},
];
// Mock API to capture the history it receives
let capturedContents: Content[] = [];
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async (req) => {
capturedContents = req.contents as Content[];
return (async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Analysis done' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
},
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-pro' },
audioParts,
'test-id',
new AbortController().signal,
LlmRole.MAIN,
);
for await (const _ of stream) {
// No-op
}
// Verify history expansion
// Turn 1: Tool response (cleaned)
// Turn 2: Model Ack (synthetic)
// Turn 3: User Binary data (current request)
expect(capturedContents).toHaveLength(3);
expect(capturedContents[0].role).toBe('user');
expect(capturedContents[0].parts![0].functionResponse!.response).toEqual({
output: 'Success',
});
expect(capturedContents[1].role).toBe('model');
expect(capturedContents[1].parts![0].text).toContain(
'Binary content received',
);
expect(capturedContents[1].parts![0].thoughtSignature).toBe(
SYNTHETIC_THOUGHT_SIGNATURE,
);
expect(capturedContents[2].role).toBe('user');
expect(capturedContents[2].parts![0].inlineData!.mimeType).toBe(
'audio/mpeg',
);
});
it('should handle multiple parallel binary injections', async () => {
const parallelParts = [
{
functionResponse: {
id: 'call-1',
name: 'read_file',
response: {
output: 'Success 1',
[BINARY_INJECTION_KEY]: [
{ inlineData: { mimeType: 'audio/mpeg', data: 'audio1' } },
],
},
},
},
{
functionResponse: {
id: 'call-2',
name: 'read_file',
response: {
output: 'Success 2',
[BINARY_INJECTION_KEY]: [
{ inlineData: { mimeType: 'video/mp4', data: 'video2' } },
],
},
},
},
];
let capturedContents: Content[] = [];
vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
async (req) => {
capturedContents = req.contents as Content[];
return (async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Done' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
},
);
const stream = await chat.sendMessageStream(
{ model: 'gemini-pro' },
parallelParts,
'test-id',
new AbortController().signal,
LlmRole.MAIN,
);
for await (const _ of stream) {
// No-op
}
// Turn 1: Cleaned tool responses (both)
// Turn 2: Model Ack
// Turn 3: Both binary parts combined
expect(capturedContents).toHaveLength(3);
expect(capturedContents[0].parts).toHaveLength(2);
expect(capturedContents[0].parts![0].functionResponse!.response).toEqual({
output: 'Success 1',
});
expect(capturedContents[0].parts![1].functionResponse!.response).toEqual({
output: 'Success 2',
});
expect(capturedContents[2].parts).toHaveLength(2);
expect(capturedContents[2].parts![0].inlineData!.mimeType).toBe(
'audio/mpeg',
);
expect(capturedContents[2].parts![1].inlineData!.mimeType).toBe(
'video/mp4',
);
});
});
describe('recordCompletedToolCalls', () => {
it('should use originalRequestName and originalRequestArgs if present', () => {
const completedCall: CompletedToolCall = {
+52 -1
View File
@@ -50,6 +50,7 @@ import { handleFallback } from '../fallback/handler.js';
import { isFunctionResponse } from '../utils/messageInspectors.js';
import { scrubHistory } from '../utils/historyHardening.js';
import { partListUnionToString } from './geminiRequest.js';
import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js';
import type { ModelConfigKey } from '../services/modelConfigService.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
import {
@@ -336,7 +337,7 @@ export class GeminiChat {
});
this.sendPromise = streamDonePromise;
const userContent = createUserContent(message);
let userContent = createUserContent(message);
const { model } =
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
@@ -366,6 +367,30 @@ export class GeminiChat {
}
// Add user content to history ONCE before any attempts.
const binaryInjections = this.extractBinaryInjections(userContent.parts);
if (binaryInjections) {
// Turn 1: The original tool response (now cleaned)
this.agentHistory.push(userContent);
// Turn 2: Synthetic Model Acknowledgment
this.agentHistory.push({
role: 'model',
parts: [
{
text: 'Binary content received. Proceeding with analysis.',
thought: true,
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
},
],
});
// Turn 3: The actual binary data (becomes the current request message)
userContent = {
role: 'user',
parts: binaryInjections,
};
}
this.agentHistory.push(userContent);
const requestContents = this.getHistory(true);
@@ -510,6 +535,32 @@ export class GeminiChat {
return streamWithRetries.call(this);
}
private extractBinaryInjections(
parts: Part[] | undefined,
): Part[] | undefined {
if (!parts) {
return undefined;
}
const binaryInjections: Part[] = [];
for (const part of parts) {
const response = part.functionResponse?.response;
if (response && BINARY_INJECTION_KEY in response) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const binaryParts = response[BINARY_INJECTION_KEY] as Part[];
delete response[BINARY_INJECTION_KEY];
if (Array.isArray(binaryParts)) {
binaryInjections.push(...binaryParts);
}
}
}
return binaryInjections.length > 0 ? binaryInjections : undefined;
}
private async makeApiCallAndProcessStream(
modelConfigKey: ModelConfigKey,
requestContents: readonly Content[],
+80 -1
View File
@@ -5,7 +5,12 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AskUserTool, isCompletedAskUserTool } from './ask-user.js';
import {
AskUserTool,
isCompletedAskUserTool,
type AskUserParams,
type AskUserInvocation,
} from './ask-user.js';
import { QuestionType, type Question } from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { ToolConfirmationOutcome } from './tools.js';
@@ -63,6 +68,80 @@ describe('AskUserTool', () => {
expect(tool.displayName).toBe('Ask User');
});
describe('createInvocation and normalization', () => {
it('should unescape double-escaped newlines in question parameters', async () => {
const params: AskUserParams = {
questions: [
{
question: 'Line 1\\nLine 2',
header: 'Header\\nTest',
placeholder: 'Placeholder\\nTest',
type: QuestionType.CHOICE,
options: [
{ label: 'Option\\n1', description: 'Desc\\n1' },
{ label: 'Option\\n2', description: 'Desc\\n2' },
],
},
],
};
const invocation = (
tool as unknown as {
createInvocation: (
params: AskUserParams,
messageBus: MessageBus,
toolName: string,
toolDisplayName: string,
) => AskUserInvocation;
}
).createInvocation(params, mockMessageBus, 'ask_user', 'Ask User');
const details = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
if (!details || details.type !== 'ask_user') {
throw new Error('Expected ask_user details');
}
expect(details.questions[0].question).toBe('Line 1\nLine 2');
expect(details.questions[0].header).toBe('Header\nTest');
expect(details.questions[0].placeholder).toBe('Placeholder\nTest');
expect(details.questions[0].options?.[0].label).toBe('Option\n1');
expect(details.questions[0].options?.[0].description).toBe('Desc\n1');
});
it('should handle carriage returns and literal newlines', async () => {
const params: AskUserParams = {
questions: [
{
question: 'Line 1\\r\\nLine 2\nLine 3',
header: 'Header',
type: QuestionType.TEXT,
},
],
};
const invocation = (
tool as unknown as {
createInvocation: (
params: AskUserParams,
messageBus: MessageBus,
toolName: string,
toolDisplayName: string,
) => AskUserInvocation;
}
).createInvocation(params, mockMessageBus, 'ask_user', 'Ask User');
const details = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
if (!details || details.type !== 'ask_user') {
throw new Error('Expected ask_user details');
}
expect(details.questions[0].question).toBe('Line 1\nLine 2\nLine 3');
});
});
describe('validateToolParams', () => {
it('should return error if questions is missing', () => {
// @ts-expect-error - Intentionally invalid params
+32 -1
View File
@@ -93,7 +93,38 @@ export class AskUserTool extends BaseDeclarativeTool<
toolName: string,
toolDisplayName: string,
): AskUserInvocation {
return new AskUserInvocation(params, messageBus, toolName, toolDisplayName);
const unescape = (str: string): string =>
str.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n');
const normalizedParams: AskUserParams = {
questions: params.questions.map((q) => {
const normalizedQ: Question = {
...q,
type: q.type,
question: unescape(q.question),
};
if (q.header) normalizedQ.header = unescape(q.header);
if (q.placeholder) normalizedQ.placeholder = unescape(q.placeholder);
if (q.options) {
normalizedQ.options = q.options.map((opt) => ({
...opt,
label: unescape(opt.label),
description: opt.description?.trim()
? unescape(opt.description.trim())
: '',
}));
}
return normalizedQ;
}),
};
return new AskUserInvocation(
normalizedParams,
messageBus,
toolName,
toolDisplayName,
);
}
override async validateBuildAndExecute(
+2
View File
@@ -1330,6 +1330,7 @@ describe('RipGrepTool', () => {
const result = await invocation.execute({ abortSignal });
const spawnArgs = mockSpawn.mock.calls[0][1];
expect(spawnArgs).toContain('--hidden');
expect(spawnArgs).toContain('--fixed-strings');
expect(spawnArgs).toContain('--regexp');
expect(spawnArgs).toContain('hello.world');
@@ -1777,6 +1778,7 @@ describe('RipGrepTool', () => {
await invocation.execute({ abortSignal });
const spawnArgs = mockSpawn.mock.calls[0][1];
expect(spawnArgs).toContain('--hidden');
expect(spawnArgs).toContain('--max-count');
expect(spawnArgs).toContain('1');
});
+1 -1
View File
@@ -402,7 +402,7 @@ class GrepToolInvocation extends BaseToolInvocation<
const searchPaths = Array.isArray(path) ? path : [path];
const rgArgs = ['--json'];
const rgArgs = ['--json', '--hidden'];
if (!case_sensitive) {
rgArgs.push('--ignore-case');
@@ -158,6 +158,57 @@ describe('generateContentResponseUtilities', () => {
]);
});
it('should filter out audio/video MIME types and add a minimal system note (generic tool)', () => {
const llmContent: PartListUnion = [
{ text: 'Some text' },
{ inlineData: { mimeType: 'audio/mpeg', data: 'audio_data' } },
];
const result = convertToFunctionResponse(
'other_tool',
callId,
llmContent,
PREVIEW_GEMINI_MODEL,
);
const frPart = result.find((p) => p.functionResponse);
const response: Record<string, unknown> = {};
if (frPart?.functionResponse?.response) {
Object.assign(response, frPart.functionResponse.response);
}
const output = response['output'] as string;
expect(output).toContain(
'[SYSTEM: Binary content (audio/mpeg) stripped from response due to protocol limitations.]',
);
expect(output).not.toContain('__binary_injection__');
});
it('should use the __binary_injection__ flag for read_file and read_many_files tools', () => {
const llmContent: PartListUnion = [
{ text: 'Reading audio' },
{ inlineData: { mimeType: 'audio/mpeg', data: 'audio_data' } },
];
for (const tool of ['read_file', 'read_many_files']) {
const result = convertToFunctionResponse(
tool,
callId,
llmContent,
PREVIEW_GEMINI_MODEL,
);
const frPart = result.find((p) => p.functionResponse);
const response: Record<string, unknown> = {};
if (frPart?.functionResponse?.response) {
Object.assign(response, frPart.functionResponse.response);
}
expect(response['output']).toContain('read successfully');
expect(response['__binary_injection__']).toBeDefined();
const injection = response['__binary_injection__'] as Part[];
expect(injection[0].inlineData?.mimeType).toBe('audio/mpeg');
}
});
it('should handle llmContent with fileData for Gemini 3 model (should be siblings)', () => {
const llmContent: Part = {
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
@@ -15,6 +15,8 @@ import { supportsMultimodalFunctionResponse } from '../config/models.js';
import { debugLogger } from './debugLogger.js';
import type { Config } from '../config/config.js';
export const BINARY_INJECTION_KEY = '__binary_injection__';
/**
* Formats tool output for a Gemini FunctionResponse.
*/
@@ -89,6 +91,43 @@ export function convertToFunctionResponse(
// Ignore other part types
}
// build a list of unsupported MIME types for function responses
const filteredInlineDataParts: Part[] = [];
const unsupportedInlineDataParts: Part[] = [];
for (const part of inlineDataParts) {
const mimeType = part.inlineData?.mimeType;
if (
mimeType &&
(mimeType.startsWith('audio/') || mimeType.startsWith('video/'))
) {
unsupportedInlineDataParts.push(part);
} else {
filteredInlineDataParts.push(part);
}
}
if (unsupportedInlineDataParts.length > 0) {
const uniqueMimes = Array.from(
new Set(
unsupportedInlineDataParts.map((p) => p.inlineData?.mimeType ?? ''),
),
).join(', ');
const isReadFileTool =
toolName === 'read_file' || toolName === 'read_many_files';
if (isReadFileTool) {
textParts.unshift(
`Binary content (${uniqueMimes}) read successfully. Content will be injected for analysis in the next sequence.`,
);
} else {
textParts.unshift(
`[SYSTEM: Binary content (${uniqueMimes}) stripped from response due to protocol limitations.]`,
);
}
}
// Build the primary response part
const part: Part = {
functionResponse: {
@@ -98,30 +137,40 @@ export function convertToFunctionResponse(
},
};
const isReadFileTool =
toolName === 'read_file' || toolName === 'read_many_files';
if (unsupportedInlineDataParts.length > 0 && isReadFileTool) {
if (part.functionResponse) {
Object.assign(part.functionResponse.response!, {
[BINARY_INJECTION_KEY]: unsupportedInlineDataParts,
});
}
}
const isMultimodalFRSupported = supportsMultimodalFunctionResponse(
model,
config,
);
const siblingParts: Part[] = [...fileDataParts];
if (inlineDataParts.length > 0) {
if (filteredInlineDataParts.length > 0) {
if (isMultimodalFRSupported) {
// Nest inlineData if supported by the model
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(part.functionResponse as unknown as { parts: Part[] }).parts =
inlineDataParts;
Object.assign(part.functionResponse!, { parts: filteredInlineDataParts });
} else {
// Otherwise treat as siblings
siblingParts.push(...inlineDataParts);
siblingParts.push(...filteredInlineDataParts);
}
}
// Add descriptive text if the response object is empty but we have binary content
if (
textParts.length === 0 &&
(inlineDataParts.length > 0 || fileDataParts.length > 0)
(filteredInlineDataParts.length > 0 || fileDataParts.length > 0)
) {
const totalBinaryItems = inlineDataParts.length + fileDataParts.length;
const totalBinaryItems =
filteredInlineDataParts.length + fileDataParts.length;
part.functionResponse!.response = {
output: `Binary content provided (${totalBinaryItems} item(s)).`,
};
+38
View File
@@ -16,6 +16,27 @@ import {
import { GeminiCliSession } from './session.js';
import type { GeminiCliAgentOptions } from './types.js';
/**
* The main entry point for the Gemini CLI SDK.
*
* An agent encapsulates configuration (instructions, tools, skills, model)
* and can create new sessions or resume existing ones.
*
* @example
* ```typescript
* const agent = new GeminiCliAgent({
* instructions: 'You are a helpful coding assistant.',
* tools: [myTool],
* });
*
* const session = agent.session();
* await session.initialize();
*
* for await (const event of session.sendStream('Hello!')) {
* console.log(event);
* }
* ```
*/
export class GeminiCliAgent {
private options: GeminiCliAgentOptions;
@@ -23,11 +44,28 @@ export class GeminiCliAgent {
this.options = options;
}
/**
* Create a new conversation session.
*
* @param options - Optional session configuration. Pass `{ sessionId }` to
* use a specific session ID; otherwise a new one is generated.
* @returns A new {@link GeminiCliSession} instance.
*/
session(options?: { sessionId?: string }): GeminiCliSession {
const sessionId = options?.sessionId || createSessionId();
return new GeminiCliSession(this.options, sessionId, this);
}
/**
* Resume a previously created session by its ID.
*
* Looks up the session's conversation history from storage and replays it
* so the agent can continue the conversation.
*
* @param sessionId - The ID of the session to resume.
* @returns A {@link GeminiCliSession} with the prior conversation loaded.
* @throws {Error} If no sessions exist or the specified ID is not found.
*/
async resumeSession(sessionId: string): Promise<GeminiCliSession> {
const cwd = this.options.cwd || process.cwd();
const storage = new Storage(cwd);
+7
View File
@@ -8,6 +8,13 @@ import type { Config as CoreConfig } from '@google/gemini-cli-core';
import type { AgentFilesystem } from './types.js';
import fs from 'node:fs/promises';
/**
* SDK implementation of {@link AgentFilesystem} that enforces path-based
* access policies from the core Config.
*
* Read operations return `null` when access is denied; write operations
* throw an error.
*/
export class SdkAgentFilesystem implements AgentFilesystem {
constructor(private readonly config: CoreConfig) {}
+40
View File
@@ -35,6 +35,15 @@ import type {
import type { SkillReference } from './skills.js';
import type { GeminiCliAgent } from './agent.js';
/**
* Represents an interactive conversation session with a Gemini CLI agent.
*
* A session manages the conversation lifecycle: initialization, sending messages
* via streaming, handling tool calls, and maintaining conversation history.
*
* Create a session via {@link GeminiCliAgent.session} or resume one with
* {@link GeminiCliAgent.resumeSession}.
*/
export class GeminiCliSession {
private readonly config: Config;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -86,10 +95,20 @@ export class GeminiCliSession {
this.config = new Config(configParams);
}
/**
* The unique identifier for this session.
*/
get id(): string {
return this.sessionId;
}
/**
* Initialize the session by setting up authentication, loading skills,
* and registering tools. Must be called before {@link sendStream}.
*
* This method is idempotent calling it multiple times has no effect
* after the first successful initialization.
*/
async initialize(): Promise<void> {
if (this.initialized) return;
@@ -168,6 +187,27 @@ export class GeminiCliSession {
this.initialized = true;
}
/**
* Send a prompt to the model and yield streaming events as they arrive.
*
* Handles the full agentic loop: sends the user prompt, streams model
* responses, executes any tool calls the model requests, and continues
* the loop until the model produces a final response with no tool calls.
*
* @param prompt - The user message to send.
* @param signal - Optional {@link AbortSignal} to cancel the stream.
* @yields {@link ServerGeminiStreamEvent} events as they are received from
* the model.
*
* @example
* ```typescript
* for await (const event of session.sendStream('Explain this code')) {
* if (event.type === GeminiEventType.ModelResponse) {
* process.stdout.write(event.value);
* }
* }
* ```
*/
async *sendStream(
prompt: string,
signal?: AbortSignal,
+12
View File
@@ -16,6 +16,18 @@ import type {
AgentShellOptions,
} from './types.js';
/**
* SDK implementation of {@link AgentShell} that executes commands via the
* core ShellExecutionService, subject to the agent's security policies.
*
* Commands that require interactive confirmation will be rejected since
* no interactive session is available in headless SDK mode.
*
* @remarks In this implementation, stderr is combined into stdout by the
* underlying ShellExecutionService. As a result, the stderr field of the
* returned {@link AgentShellResult} will be empty, and both output and
* stdout will contain the combined output.
*/
export class SdkAgentShell implements AgentShell {
constructor(private readonly config: CoreConfig) {}
+6
View File
@@ -4,6 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* A reference to a skill directory that can be loaded by the agent.
*
* Skills extend the agent's capabilities by providing additional prompts,
* tools, and behaviors defined in a directory structure.
*/
export type SkillReference = { type: 'dir'; path: string };
/**
+80
View File
@@ -19,6 +19,11 @@ import type { SessionContext } from './types.js';
export { z };
/**
* An error that, when thrown from a tool's action, will be visible to the
* Gemini model in the conversation. Useful for providing feedback to the
* model about why a tool failed so it can retry or adjust its approach.
*/
export class ModelVisibleError extends Error {
constructor(message: string | Error) {
super(message instanceof Error ? message.message : message);
@@ -26,14 +31,56 @@ export class ModelVisibleError extends Error {
}
}
/**
* The declarative definition of a tool, including its name, description,
* Zod input schema, and optional error-handling behavior.
*
* @typeParam T - The Zod schema type that validates the tool's input parameters.
*/
export interface ToolDefinition<T extends z.ZodTypeAny> {
/**
* A unique name for the tool, used by the model to invoke it.
*/
name: string;
/**
* A human-readable description of what the tool does.
* This is sent to the model to help it decide when to use the tool.
*/
description: string;
/**
* A Zod schema that validates and type-checks the tool's input parameters.
*/
inputSchema: T;
/**
* When `true`, any errors thrown by the tool's action will be sent back
* to the model as part of the conversation. Defaults to `false`.
*/
sendErrorsToModel?: boolean;
}
/**
* A complete tool definition that combines a {@link ToolDefinition} with
* an executable action function.
*
* The action receives validated parameters (inferred from the Zod schema)
* and an optional {@link SessionContext}, and returns an arbitrary result
* that will be serialized and sent back to the model.
*
* @typeParam T - The Zod schema type that validates the tool's input parameters.
*/
export interface Tool<T extends z.ZodTypeAny> extends ToolDefinition<T> {
/**
* The function executed when the model invokes this tool.
*
* @param params - The validated input parameters, typed from the Zod schema.
* @param context - Optional session context providing access to filesystem,
* shell, and other session state.
* @returns A promise resolving to the tool's output, which will be
* serialized (to JSON if not already a string) and sent to the model.
*/
action: (params: z.infer<T>, context?: SessionContext) => Promise<unknown>;
}
@@ -88,6 +135,14 @@ class SdkToolInvocation<T extends z.ZodTypeAny> extends BaseToolInvocation<
}
}
/**
* A wrapper that integrates an SDK {@link Tool} into the core tool registry.
*
* Handles parameter validation, execution, error handling (including
* {@link ModelVisibleError}), and context binding for tool invocations.
*
* @typeParam T - The Zod schema type that validates the tool's input parameters.
*/
export class SdkTool<T extends z.ZodTypeAny> extends BaseDeclarativeTool<
z.infer<T>,
ToolResult
@@ -144,6 +199,31 @@ export class SdkTool<T extends z.ZodTypeAny> extends BaseDeclarativeTool<
}
}
/**
* Helper function to create a {@link Tool} by combining a definition and an action.
*
* @typeParam T - The Zod schema type for the tool's input parameters.
* @param definition - The tool's name, description, and input schema.
* @param action - The async function to execute when the tool is invoked.
* @returns A complete {@link Tool} object ready to be passed to
* {@link GeminiCliAgentOptions.tools}.
*
* @example
* ```typescript
* import { z, tool } from '@google/gemini-cli-sdk';
*
* const myTool = tool(
* {
* name: 'get_weather',
* description: 'Get the current weather for a location',
* inputSchema: z.object({ city: z.string() }),
* },
* async (params) => {
* return `Weather in ${params.city}: Sunny, 25°C`;
* },
* );
* ```
*/
export function tool<T extends z.ZodTypeAny>(
definition: ToolDefinition<T>,
action: (params: z.infer<T>, context?: SessionContext) => Promise<unknown>,
+145 -38
View File
@@ -11,8 +11,11 @@ import type { GeminiCliAgent } from './agent.js';
import type { GeminiCliSession } from './session.js';
/**
* Instructions that guide the agent's behavior and personality.
* Can be a static string or a dynamic function that receives the current session context.
* System instructions for a Gemini CLI agent.
*
* Can be either a static string or a function that receives the current
* session context and returns a string (or a promise of one), allowing
* dynamic instructions that change based on conversation state.
*
* @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: If using a dynamic function, ensure that any data from the
* session context is sanitized (e.g., removing newlines, ']', and escaping '<', '>')
@@ -23,55 +26,108 @@ export type SystemInstructions =
| ((context: SessionContext) => string | Promise<string>);
/**
* Configuration options for creating a GeminiCliAgent.
* Configuration options for creating a {@link GeminiCliAgent}.
*/
export interface GeminiCliAgentOptions {
/**
* The system instructions defining the agent's behavior.
* System instructions that define the agent's behavior.
* Can be a static string or a dynamic function that receives session context.
*
* @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: If using a dynamic function, sanitize all input from the
* SessionContext (e.g., removing newlines, ']', and escaping '<', '>') to prevent prompt injection.
*/
instructions: SystemInstructions;
/** Optional list of tools the agent can use. */
/**
* Custom tools to register with the agent.
* Each tool is defined using a Zod schema for input validation.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: Array<Tool<any>>;
/** Optional list of skills the agent possesses. */
/**
* Skill directories to load into the agent's skill set.
*/
skills?: SkillReference[];
/** The model name to use (e.g., 'gemini-1.5-pro'). */
/**
* The Gemini model to use for this agent.
* Defaults to the auto-selected model if not specified.
*/
model?: string;
/** The current working directory for the agent. */
/**
* The working directory for the agent.
* Defaults to `process.cwd()` if not specified.
*/
cwd?: string;
/** Whether to enable debug logging. */
/**
* Whether to enable debug mode for verbose logging.
* Defaults to `false`.
*/
debug?: boolean;
/** Optional path to record agent responses for testing. */
/**
* File path to record agent responses to for debugging/replay.
*/
recordResponses?: string;
/** Optional path to load fake responses for testing. */
/**
* File path to load fake/resimulated responses from for testing.
*/
fakeResponses?: string;
}
/**
* Interface for basic filesystem operations that the agent can perform.
* A virtual filesystem interface available to agents during tool execution.
*
* Provides sandboxed read/write access to files, subject to the agent's
* configured path access policies.
*
* Note: Implementations must internally validate and sanitize file paths to
* prevent path traversal attacks (e.g., checking for '..' or null bytes)
* using robust functions like resolveToRealPath.
*/
export interface AgentFilesystem {
/** Reads the content of a file at the given path. */
/**
* Read the contents of a file.
*
* @param path - Absolute or relative path to the file.
* @returns The file contents as a UTF-8 string, or `null` if the file
* does not exist or access is denied.
*/
readFile(path: string): Promise<string | null>;
/** Writes content to a file at the given path. */
/**
* Write content to a file.
*
* @param path - Absolute or relative path to the file.
* @param content - The content to write.
* @throws {Error} If write access is denied by the agent's policy.
*/
writeFile(path: string, content: string): Promise<void>;
}
/**
* Options for executing shell commands.
* Options for configuring shell command execution via {@link AgentShell.exec}.
*/
export interface AgentShellOptions {
/** Environment variables for the shell process. */
/**
* Environment variables to set for the command execution.
* These are merged with the default environment.
*/
env?: Record<string, string>;
/** Timeout for the command in seconds. */
/**
* Maximum time in seconds to wait for the command to complete.
*/
timeoutSeconds?: number;
/** The working directory where the command should be executed. */
/**
* Working directory in which to execute the command.
* Defaults to the agent's configured working directory.
*/
cwd?: string;
}
@@ -79,56 +135,107 @@ export interface AgentShellOptions {
* The result of a shell command execution.
*/
export interface AgentShellResult {
/** The exit code of the process, or null if it was terminated. */
/**
* The exit code of the process, or `null` if the process was killed
* or did not exit normally.
*/
exitCode: number | null;
/** The combined output of stdout and stderr. */
/**
* The combined stdout and stderr output of the command.
*/
output: string;
/** The content written to stdout. */
/**
* The standard output stream content.
*/
stdout: string;
/** The content written to stderr. */
/**
* The standard error stream content.
*/
stderr: string;
/** Any error that occurred during execution. */
/**
* An error object if the command failed to execute or was rejected
* by policy.
*/
error?: Error;
}
/**
* Interface for executing shell commands within the agent's environment.
* A shell interface for executing commands within an agent's sandboxed environment.
*
* Commands are subject to the agent's security policies and may be rejected
* if they require interactive confirmation.
*/
export interface AgentShell {
/**
* Executes a shell command and returns the result.
* Execute a shell command.
*
* @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: Ensure the command string is properly sanitized and does
* not contain unvalidated user or LLM input to prevent command injection.
*
* @param cmd - The command string to execute.
* @param options - Optional execution configuration.
* @returns A promise resolving to the command result.
*/
exec(cmd: string, options?: AgentShellOptions): Promise<AgentShellResult>;
}
/**
* Contextual information provided to tools and dynamic instructions during a session.
* Contextual information about the current session, passed to tools and
* dynamic system instruction functions.
*
* Provides access to session metadata, conversation history, filesystem,
* shell, and the parent agent/session instances.
*/
export interface SessionContext {
/** Unique identifier for the current session. */
sessionId: string;
/** The full transcript of the conversation so far. */
transcript: readonly Content[];
/** The current working directory of the session. */
cwd: string;
/** The ISO timestamp of when the context was generated. */
timestamp: string;
/**
* Access to the filesystem for the agent.
* Unique identifier for the current session.
*/
sessionId: string;
/**
* Read-only transcript of the conversation so far, including user
* messages and model responses.
*/
transcript: readonly Content[];
/**
* The current working directory of the session.
*/
cwd: string;
/**
* ISO 8601 timestamp of when this context was created.
*/
timestamp: string;
/**
* Virtual filesystem for reading and writing files within the agent's
* sandbox.
*
* @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: This provides full access to the agent's filesystem.
* Ensure tools using this are trusted and validate their inputs.
*/
fs: AgentFilesystem;
/**
* Access to the shell for the agent.
* Shell interface for executing commands within the agent's sandbox.
*
* @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: This provides full access to the agent's shell.
* Any tool receiving this context can execute arbitrary commands.
*/
shell: AgentShell;
/** Reference to the current GeminiCliAgent instance. */
/**
* The parent agent that owns this session.
*/
agent: GeminiCliAgent;
/** Reference to the current GeminiCliSession instance. */
/**
* The current session instance.
*/
session: GeminiCliSession;
}
+7
View File
@@ -3031,6 +3031,13 @@
"type": "string"
}
},
"ignoreLocalEnv": {
"title": "Ignore Local .env",
"description": "Whether to ignore generic .env files in the project directory.",
"markdownDescription": "Whether to ignore generic .env files in the project directory.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"bugCommand": {
"title": "Bug Command",
"description": "Configuration for the bug report command.",
+12 -6
View File
@@ -42,13 +42,19 @@ if (!existsSync(generatedCoreDir)) {
}
try {
const gitHash = execSync('git rev-parse --short HEAD', {
encoding: 'utf-8',
}).trim();
if (gitHash) {
gitCommitInfo = gitHash;
// Check for GIT_COMMIT env var first (e.g. when building inside Docker
// without a .git directory available)
const envCommit = process.env.GIT_COMMIT;
if (envCommit && /^[0-9a-f]+$/i.test(envCommit)) {
gitCommitInfo = envCommit;
} else {
const gitHash = execSync('git rev-parse --short HEAD', {
encoding: 'utf-8',
}).trim();
if (gitHash) {
gitCommitInfo = gitHash;
}
}
const result = await readPackageUp();
cliVersion = result?.packageJson?.version ?? 'UNKNOWN';
} catch {
+4
View File
@@ -47,6 +47,10 @@ synchronize with previous sessions:
than closure rates).
- **Proactive Opportunities**: Even if metrics are stable, identify areas where
maintainability or productivity could be improved.
- **Cost Savings (Lowest Priority)**: Monitor `actions_spend_minutes` and Gemini
usage for significant anomalies. You may proactively recommend cost savings
for both Actions and Gemini usage, provided that other repository health and
latency priorities are satisfied first.
### 2. Hypothesis Testing & Deep Dive
@@ -0,0 +1,125 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync } from 'node:child_process';
async function getWorkflowMinutes(): Promise<Record<string, number>> {
const sevenDaysAgoDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
const output = execFileSync(
'gh',
[
'run',
'list',
'--limit',
'1000',
'--created',
`>=${sevenDaysAgoDate}`,
'--json',
'databaseId,workflowName',
],
{ encoding: 'utf-8' },
);
const runs = JSON.parse(output);
const workflowMinutes: Record<string, number> = {};
const token = execFileSync('gh', ['auth', 'token'], {
encoding: 'utf-8',
}).trim();
const repoInfo = JSON.parse(
execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner'], {
encoding: 'utf-8',
}),
);
const repoName = repoInfo.nameWithOwner;
const chunkSize = 20;
for (let i = 0; i < runs.length; i += chunkSize) {
const chunk = runs.slice(i, i + chunkSize);
await Promise.all(
chunk.map(async (r: { databaseId: number; workflowName?: string }) => {
try {
const res = await fetch(
`https://api.github.com/repos/${repoName}/actions/runs/${r.databaseId}/jobs`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
},
},
);
if (!res.ok) return;
const { jobs } = await res.json();
let runBillableMinutes = 0;
for (const job of jobs || []) {
if (!job.started_at || !job.completed_at) continue;
const start = new Date(job.started_at).getTime();
const end = new Date(job.completed_at).getTime();
const durationMs = end - start;
if (durationMs > 0) {
runBillableMinutes += Math.ceil(durationMs / (1000 * 60));
}
}
if (runBillableMinutes > 0) {
const name = r.workflowName || 'Unknown';
workflowMinutes[name] =
(workflowMinutes[name] || 0) + runBillableMinutes;
}
} catch {
// Ignore failures for individual runs
}
}),
);
}
return workflowMinutes;
}
async function run() {
try {
const workflowMinutes = await getWorkflowMinutes();
let totalMinutes = 0;
for (const minutes of Object.values(workflowMinutes)) {
totalMinutes += minutes;
}
const now = new Date().toISOString();
console.log(
JSON.stringify({
metric: 'actions_spend_minutes',
value: totalMinutes,
timestamp: now,
details: workflowMinutes,
}),
);
for (const [name, minutes] of Object.entries(workflowMinutes)) {
const safeName = name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
console.log(
JSON.stringify({
metric: `actions_spend_minutes_workflow:${safeName}`,
value: minutes,
timestamp: now,
}),
);
}
} catch (error) {
process.stderr.write(
error instanceof Error ? error.message : String(error),
);
process.exit(1);
}
}
run();