Compare commits

..

24 Commits

Author SHA1 Message Date
gemini-cli-robot 8db7275525 chore(release): v0.42.0-preview.2 2026-05-06 17:58:23 +00:00
gemini-cli-robot 6837b08a03 fix(patch): cherry-pick 02995ba to release/v0.42.0-preview.1-pr-26568 to patch version v0.42.0-preview.1 and create version 0.42.0-preview.2 (#26590)
Co-authored-by: Keith Schaab <keith.schaab@gmail.com>
2026-05-06 10:09:19 -07:00
gemini-cli-robot 92f410d6db chore(release): v0.42.0-preview.1 2026-05-05 22:40:14 +00:00
gemini-cli-robot 246f984599 fix(patch): cherry-pick 3627f47 to release/v0.42.0-preview.0-pr-26542 to patch version v0.42.0-preview.0 and create version 0.42.0-preview.1 (#26544)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-05-05 21:57:21 +00:00
gemini-cli-robot 3c12065401 chore(release): v0.42.0-preview.0 2026-05-05 20:32:31 +00:00
joshualitt 0803007c8f fix(core): Minor fixes for generalist profile. (#26357) 2026-05-05 19:32:13 +00:00
Coco Sheng f5c0977e96 fix(core): retry on ERR_STREAM_PREMATURE_CLOSE errors (#26519) 2026-05-05 19:19:50 +00:00
Coco Sheng e80d7cc083 feat: allow queuing messages during compression (#24071) (#26506) 2026-05-05 17:52:08 +00:00
Jack Wotherspoon 7cc19c2a1b fix(cli): prevent settings dialog border clipping using maxHeight (#26507) 2026-05-05 16:22:58 +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
Adib234 75a8de83fc test(cleanup): fix temporary directory leaks in test suites (#26217) 2026-05-04 19:08:02 +00:00
Sandy Tao a7beb890d0 feat(memory): add Auto Memory inbox flow with canonical-patch contract (#26338) 2026-05-04 19:07:13 +00:00
111 changed files with 9179 additions and 2148 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"]
+2 -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
@@ -177,7 +178,7 @@ they appear in the UI.
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
| Auto Memory | `experimental.autoMemory` | Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
+10 -2
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`
@@ -1927,8 +1933,10 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`experimental.autoMemory`** (boolean):
- **Description:** Automatically extract reusable skills from past sessions in
the background. Review results with /memory inbox.
- **Description:** Automatically extract memory patches and skills from past
sessions in the background. Every change is written as a unified diff
`.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review
in /memory inbox; nothing is applied until you approve it.
- **Default:** `false`
- **Requires restart:** Yes
+489
View File
@@ -0,0 +1,489 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Live-LLM evals that pin down the auto-memory inbox contract:
* 1. Canonical filename — agent uses `.inbox/<kind>/extraction.patch`.
* 2. Incremental merge — agent rewrites an existing extraction.patch
* instead of creating new patch files alongside.
* 3. Absolute-path pointers — when the agent creates a sibling .md, the
* paired MEMORY.md hunk references it by absolute path.
* 4. Project-root protection — agent never writes to
* `<projectRoot>/GEMINI.md` even when content is team-shared.
*
* Each test seeds session transcripts with strong, consistent signal so the
* extraction agent will reasonably produce SOME output (or, in the human-only
* test, refrain from producing output that targets forbidden paths). Tests
* are USUALLY_PASSES policy because LLM behavior is stochastic; the harness
* already retries up to 3 times.
*/
import fsp from 'node:fs/promises';
import path from 'node:path';
import { describe, expect } from 'vitest';
import {
type Config,
ApprovalMode,
SESSION_FILE_PREFIX,
getProjectHash,
startMemoryService,
} from '@google/gemini-cli-core';
import { componentEvalTest } from './component-test-helper.js';
interface SeedSession {
sessionId: string;
summary: string;
userTurns: string[];
/** Minutes ago the session ended (must be ≥ 180 to clear the idle gate). */
timestampOffsetMinutes: number;
}
interface MessageRecord {
id: string;
timestamp: string;
type: string;
content: Array<{ text: string }>;
}
const WORKSPACE_FILES = {
'package.json': JSON.stringify(
{
name: 'auto-memory-contract-eval',
private: true,
scripts: { build: 'echo build', test: 'echo test' },
},
null,
2,
),
'README.md': '# Auto Memory Contract Eval\n\nFixture workspace.\n',
};
const EXTRACTION_CONFIG_OVERRIDES = {
experimentalAutoMemory: true,
approvalMode: ApprovalMode.YOLO,
};
function buildMessages(userTurns: string[]): MessageRecord[] {
const baseTime = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
return userTurns.flatMap((text, index) => [
{
id: `u${index + 1}`,
timestamp: baseTime,
type: 'user',
content: [{ text }],
},
{
id: `a${index + 1}`,
timestamp: baseTime,
type: 'gemini',
content: [{ text: 'Acknowledged.' }],
},
]);
}
async function seedSessions(
config: Config,
sessions: SeedSession[],
): Promise<void> {
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
await fsp.mkdir(chatsDir, { recursive: true });
const projectRoot = config.storage.getProjectRoot();
for (const session of sessions) {
const sessionTimestamp = new Date(
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
);
const timestamp = sessionTimestamp
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${session.sessionId.slice(0, 8)}.json`;
const conversation = {
sessionId: session.sessionId,
projectHash: getProjectHash(projectRoot),
summary: session.summary,
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
lastUpdated: sessionTimestamp.toISOString(),
messages: buildMessages(session.userTurns),
};
await fsp.writeFile(
path.join(chatsDir, filename),
JSON.stringify(conversation, null, 2),
);
}
}
interface InboxSnapshot {
privateFiles: string[];
globalFiles: string[];
privateContents: Map<string, string>;
}
async function snapshotInbox(config: Config): Promise<InboxSnapshot> {
const memoryDir = config.storage.getProjectMemoryTempDir();
const inbox: InboxSnapshot = {
privateFiles: [],
globalFiles: [],
privateContents: new Map(),
};
for (const kind of ['private', 'global'] as const) {
const dir = path.join(memoryDir, '.inbox', kind);
let entries: string[];
try {
entries = await fsp.readdir(dir);
} catch {
continue;
}
const patchFiles = entries.filter((f) => f.endsWith('.patch')).sort();
if (kind === 'private') {
inbox.privateFiles = patchFiles;
for (const fileName of patchFiles) {
try {
inbox.privateContents.set(
fileName,
await fsp.readFile(path.join(dir, fileName), 'utf-8'),
);
} catch {
// ignore
}
}
} else {
inbox.globalFiles = patchFiles;
}
}
return inbox;
}
describe('Auto Memory Contract', () => {
componentEvalTest('USUALLY_PASSES', {
suiteName: 'auto-memory-contract',
suiteType: 'component-level',
name: 'uses canonical extraction.patch filename when writing private memory',
files: WORKSPACE_FILES,
timeout: 240000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
await seedSessions(config, [
{
sessionId: 'verify-memory-cmd-1',
summary:
'Confirm that this project verifies memory edits with `npm run verify:memory`',
timestampOffsetMinutes: 420,
userTurns: [
'For this project, every memory-system change is verified with `npm run verify:memory` before we hand the change back.',
'That command is the gate. Without it the change is not considered done.',
'It runs typechecks, the related unit tests, and a snapshot diff.',
'Future agents working on memory should always run it after editing memoryService or commands/memory.ts.',
'This is a durable rule for this project, not a one-off.',
'The check is fast, under a minute, and failure means revert.',
'Treat it as part of the memory subsystem contract.',
'I want this remembered for next time.',
'It applies to anything in packages/core/src/services/memoryService.ts and packages/core/src/commands/memory.ts.',
'Make sure agents do not skip the verify step.',
],
},
{
sessionId: 'verify-memory-cmd-2',
summary: 'Same memory-verify command in another session',
timestampOffsetMinutes: 360,
userTurns: [
'I had to remind the previous agent to run `npm run verify:memory` again.',
'It is the durable verification command for memory edits in this repo.',
'The agent forgot, even though we agreed last time.',
'Please remember it for future memory-related work.',
'It is the official verification step for memory changes.',
'Run it whenever you touch memoryService.ts or commands/memory.ts.',
'No exceptions. The command must finish green.',
'This is a recurring rule across multiple sessions now.',
'Make this part of your standard workflow for memory work.',
'Verified again that the command catches regressions in MEMORY.md handling.',
],
},
]);
},
assert: async (config) => {
await startMemoryService(config);
const inbox = await snapshotInbox(config);
// Either the agent extracted nothing (acceptable no-op) OR it extracted
// exactly one canonical file per kind. Multiple files per kind violates
// the contract.
expect(inbox.privateFiles.length).toBeLessThanOrEqual(1);
expect(inbox.globalFiles.length).toBeLessThanOrEqual(1);
// Strong assertion: when the agent DID write a private patch, it must
// be the canonical filename.
if (inbox.privateFiles.length === 1) {
expect(inbox.privateFiles[0]).toBe('extraction.patch');
}
if (inbox.globalFiles.length === 1) {
expect(inbox.globalFiles[0]).toBe('extraction.patch');
}
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'auto-memory-contract',
suiteType: 'component-level',
name: 'merges new findings into existing extraction.patch instead of creating new files',
files: WORKSPACE_FILES,
timeout: 240000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
const memoryDir = config.storage.getProjectMemoryTempDir();
const inboxPrivate = path.join(memoryDir, '.inbox', 'private');
await fsp.mkdir(inboxPrivate, { recursive: true });
// Pre-existing canonical patch left over from a prior session.
const existingMemoryMd = path.join(memoryDir, 'MEMORY.md');
const preExistingPatch = [
`--- /dev/null`,
`+++ ${existingMemoryMd}`,
`@@ -0,0 +1,3 @@`,
`+# Project Memory`,
`+`,
`+- This project lints with \`npm run lint\` (recurring rule from session 1).`,
``,
].join('\n');
await fsp.writeFile(
path.join(inboxPrivate, 'extraction.patch'),
preExistingPatch,
);
// New session that surfaces a different durable fact.
await seedSessions(config, [
{
sessionId: 'incremental-typecheck-cmd',
summary:
'Confirm that typecheck for memory edits uses `npm run typecheck`',
timestampOffsetMinutes: 420,
userTurns: [
'Always run `npm run typecheck` after editing any *.ts file in this repo.',
'It is the standard typecheck command for the whole monorepo.',
'Future agents should follow this without being reminded.',
'It catches type errors before tests, much faster.',
'Run it on every TypeScript edit, no exceptions.',
'This is durable across the whole project.',
'It is the project-wide convention for TS work.',
'Make sure to run it after edits to memoryService.ts especially.',
'It is fast and catches regressions early.',
'Treat it as standard workflow.',
],
},
]);
},
assert: async (config) => {
await startMemoryService(config);
const inbox = await snapshotInbox(config);
// Contract: still ONLY ONE file in private inbox, and its name is the
// canonical extraction.patch.
expect(inbox.privateFiles).toEqual(['extraction.patch']);
// The single canonical patch must STILL contain the old hunk (the
// agent must merge with existing rather than replace blindly), AND
// ideally also contain the new typecheck fact.
const merged = inbox.privateContents.get('extraction.patch') ?? '';
expect(merged).toMatch(/npm run lint/);
// Soft assertion: the agent SHOULD have added the new fact too. We
// don't fail the test if it didn't (the agent may legitimately decide
// the new fact isn't durable enough), but the file must be intact.
// The hard assertion (no proliferation + old content preserved) is
// what we lock down.
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'auto-memory-contract',
suiteType: 'component-level',
name: 'uses absolute paths in MEMORY.md sibling pointer lines',
files: WORKSPACE_FILES,
timeout: 240000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
// Sessions whose extracted memory has substantial detail — encourages
// the agent to spawn a sibling .md file (per prompt guidance).
await seedSessions(config, [
{
sessionId: 'detailed-release-workflow-1',
summary: 'Detailed release workflow that runs across multiple steps',
timestampOffsetMinutes: 420,
userTurns: [
'Our release workflow has several distinct phases that future agents need to follow exactly.',
'Phase 1 (preflight): run `npm run lint`, `npm run typecheck`, and `npm test` in that order.',
'Phase 2 (build): run `npm run build` and verify dist/ outputs against a checksum file.',
'Phase 3 (publish): run `npm run publish:dry-run` first, then `npm run publish` if no errors.',
'Phase 4 (post): tag the commit with `git tag v$(jq -r .version package.json)` and push.',
'There are pitfalls: phase 2 will silently succeed if dist/ is stale, so always check the checksum.',
'Phase 3 must NEVER be skipped for hotfixes; the dry-run catches credential issues.',
'The checklist is durable across all releases for this repo.',
'Future agents should reproduce these phases in order without omitting any.',
'This is the canonical release procedure for this project.',
],
},
{
sessionId: 'detailed-release-workflow-2',
summary: 'Reusing the same multi-phase release workflow',
timestampOffsetMinutes: 360,
userTurns: [
'I just ran the release workflow again and it caught an issue in phase 2 because the checksum mismatched.',
'Confirms the durable rule: always check the dist/ checksum after building.',
'The 4-phase release procedure (preflight, build, publish, post) is the recurring workflow.',
'I want this captured as durable memory because we use it every release.',
'Each phase has multiple sub-steps and pitfalls, so it deserves substantial detail.',
'Please remember the phases for future agents.',
'The procedure has been the same for the last 6 releases.',
'It includes the verify-checksum step that just saved us from a bad publish.',
'This is a recurring multi-step workflow, not a one-off.',
'Make sure future sessions know about all 4 phases and their pitfalls.',
],
},
]);
},
assert: async (config) => {
await startMemoryService(config);
const inbox = await snapshotInbox(config);
const memoryDir = config.storage.getProjectMemoryTempDir();
// The agent might choose to add brief facts directly to MEMORY.md
// without spawning a sibling. That's a valid outcome; we only enforce
// the absolute-path rule WHEN a sibling is created.
if (inbox.privateFiles.length === 0) {
return; // No-op extraction: nothing to assert.
}
expect(inbox.privateFiles).toEqual(['extraction.patch']);
const patch = inbox.privateContents.get('extraction.patch') ?? '';
// Find any /dev/null sibling-creation hunk that targets <memoryDir>/<x>.md
// (where x != MEMORY).
const siblingPattern = new RegExp(
`\\+\\+\\+ ${memoryDir.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}/([^\\s/]+)\\.md`,
'g',
);
const siblingTargets: string[] = [];
let match: RegExpExecArray | null;
while ((match = siblingPattern.exec(patch)) !== null) {
const name = match[1];
// Skip MEMORY.md updates (those aren't siblings).
if (name.toLowerCase() !== 'memory') {
siblingTargets.push(`${name}.md`);
}
}
if (siblingTargets.length === 0) {
return; // No sibling creations; nothing more to check.
}
// For each created sibling, the patch must contain a MEMORY.md
// pointer line that uses the ABSOLUTE path. Bare basename references
// are the bug we're guarding against.
for (const sibling of siblingTargets) {
const absolutePath = path.join(memoryDir, sibling);
// Look for an added line referencing the sibling.
const addedLines = patch
.split('\n')
.filter((line) => line.startsWith('+'));
const referencingLines = addedLines.filter((line) =>
line.includes(sibling),
);
expect(
referencingLines.length,
`Expected a MEMORY.md pointer for ${sibling} (auto-bundle would also add one).`,
).toBeGreaterThan(0);
const allAbsolute = referencingLines.every((line) =>
line.includes(absolutePath),
);
expect(
allAbsolute,
`Pointer for ${sibling} must use absolute path. Saw: ${referencingLines.join(' | ')}`,
).toBe(true);
}
},
});
componentEvalTest('USUALLY_PASSES', {
suiteName: 'auto-memory-contract',
suiteType: 'component-level',
name: 'never writes to <projectRoot>/GEMINI.md even for team-shared facts',
files: WORKSPACE_FILES,
timeout: 240000,
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
setup: async (config) => {
// Sessions that talk about TEAM CONVENTIONS — the kind of content that
// would be a perfect fit for <projectRoot>/GEMINI.md, but the prompt
// forbids the extraction agent from touching it.
await seedSessions(config, [
{
sessionId: 'team-convention-pnpm-1',
summary: 'Team convention: always use pnpm not npm for installs',
timestampOffsetMinutes: 420,
userTurns: [
'Important team-wide convention for this repo: always use pnpm for installs, never npm.',
'This is a shared rule across all engineers on the project.',
'It applies to every package install, every clean, every dependency add.',
'The rationale is workspace hoisting; npm would break the monorepo layout.',
'This is a durable team rule, committed to the repo conventions.',
'Future agents working in this repo should ALWAYS use pnpm.',
'It is the standard team practice, no exceptions.',
'Document it as part of the project conventions.',
'Treat it as a hard rule for the team.',
'I want this captured for future sessions.',
],
},
{
sessionId: 'team-convention-pnpm-2',
summary: 'Reaffirming the pnpm-only team rule in another session',
timestampOffsetMinutes: 360,
userTurns: [
'Reminder again: this team uses pnpm exclusively, never npm.',
'Another agent tried npm install and broke the lockfile.',
'The team rule is clear: pnpm only for any install operation.',
'It is part of our shared conventions for this codebase.',
'Make sure future agents follow this team-wide rule.',
'It applies to all engineers, all CI runs, all dev environments.',
'The convention is durable and well-established for this repo.',
'Agents should read this rule from project conventions before installing.',
'No future agent should ever invoke `npm install` in this repo.',
'Always pnpm. Always.',
],
},
]);
},
assert: async (config) => {
await startMemoryService(config);
const inbox = await snapshotInbox(config);
const projectRoot = config.storage.getProjectRoot();
// No private patch should target <projectRoot>/GEMINI.md or any
// subdirectory GEMINI.md.
const projectRootRegex = new RegExp(
`\\+\\+\\+ ${projectRoot.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}.*GEMINI\\.md`,
);
for (const [name, content] of inbox.privateContents) {
expect(
projectRootRegex.test(content),
`Private patch "${name}" must not target a GEMINI.md under <projectRoot>. Content:\n${content}`,
).toBe(false);
}
// Verify on disk: <projectRoot>/GEMINI.md was not created or modified
// by the extraction agent (snapshot rollback should also enforce this,
// but we double-check from the post-run state).
const projectGemini = path.join(projectRoot, 'GEMINI.md');
const exists = await fsp
.access(projectGemini)
.then(() => true)
.catch(() => false);
// The seeded workspace's WORKSPACE_FILES doesn't include GEMINI.md, so
// it must NOT exist after the run.
expect(
exists,
`<projectRoot>/GEMINI.md (${projectGemini}) must not be created by the extraction agent.`,
).toBe(false);
},
});
});
+447
View File
@@ -0,0 +1,447 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { afterEach, beforeEach, describe, expect, vi } from 'vitest';
import { runEval } from './test-helper.js';
import { SESSION_FILE_PREFIX } from '../packages/core/src/services/chatRecordingService.js';
const evalState = vi.hoisted(() => ({
sessionFilePath: '',
debugLines: [] as string[],
}));
const mocks = vi.hoisted(() => ({
localAgentCreate: vi.fn(),
}));
vi.mock('../packages/core/src/agents/local-executor.js', () => ({
LocalAgentExecutor: {
create: mocks.localAgentCreate,
},
}));
vi.mock('../packages/core/src/agents/local-executor.ts', () => ({
LocalAgentExecutor: {
create: mocks.localAgentCreate,
},
}));
vi.mock('../packages/core/src/agents/local-executor', () => ({
LocalAgentExecutor: {
create: mocks.localAgentCreate,
},
}));
vi.mock('../packages/core/src/services/executionLifecycleService.js', () => ({
ExecutionLifecycleService: {
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
completeExecution: vi.fn(),
},
}));
vi.mock('../packages/core/src/services/executionLifecycleService.ts', () => ({
ExecutionLifecycleService: {
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
completeExecution: vi.fn(),
},
}));
vi.mock('../packages/core/src/services/executionLifecycleService', () => ({
ExecutionLifecycleService: {
createExecution: vi.fn().mockReturnValue({ pid: 1001, result: {} }),
completeExecution: vi.fn(),
},
}));
vi.mock('../packages/core/src/utils/debugLogger.js', () => ({
debugLogger: {
debug: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
log: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
warn: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
error: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
},
}));
vi.mock('../packages/core/src/utils/debugLogger.ts', () => ({
debugLogger: {
debug: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
log: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
warn: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
error: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
},
}));
vi.mock('../packages/core/src/utils/debugLogger', () => ({
debugLogger: {
debug: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
log: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
warn: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
error: (...args: unknown[]) =>
evalState.debugLines.push(args.map(String).join(' ')),
},
}));
interface MockMemoryConfig {
storage: {
getProjectMemoryDir: () => string;
getProjectMemoryTempDir: () => string;
getProjectSkillsMemoryDir: () => string;
getProjectTempDir: () => string;
getProjectRoot: () => string;
};
getTargetDir: () => string;
getToolRegistry: () => unknown;
getGeminiClient: () => unknown;
getSkillManager: () => { getSkills: () => unknown[] };
isAutoMemoryEnabled: () => boolean;
modelConfigService: {
registerRuntimeModelConfig: ReturnType<typeof vi.fn>;
};
sandboxManager: undefined;
}
interface Fixture {
rootDir: string;
homeDir: string;
targetDir: string;
projectTempDir: string;
memoryDir: string;
skillsDir: string;
config: MockMemoryConfig;
}
interface AutoMemoryRunSnapshot {
sessionIds?: string[];
memoryCandidatesCreated?: string[];
memoryFilesUpdated?: string[];
skillsCreated?: string[];
}
const fixtures: Fixture[] = [];
beforeEach(() => {
vi.resetModules();
evalState.debugLines = [];
evalState.sessionFilePath = '';
mocks.localAgentCreate.mockReset();
mocks.localAgentCreate.mockImplementation(
async (_agent, context, onActivity) => ({
run: vi.fn().mockImplementation(async () => {
if (evalState.sessionFilePath) {
const callId = `read-inbox-routing`;
onActivity({
isSubagentActivityEvent: true,
agentName: 'auto-memory-eval',
type: 'TOOL_CALL_START',
data: {
name: 'read_file',
callId,
args: { file_path: evalState.sessionFilePath },
},
});
onActivity({
isSubagentActivityEvent: true,
agentName: 'auto-memory-eval',
type: 'TOOL_CALL_END',
data: { id: callId, data: { isError: false } },
});
}
const config = context.config as MockMemoryConfig;
const memoryDir = config.storage.getProjectMemoryTempDir();
const inboxDir = path.join(memoryDir, '.inbox');
const homeDir = process.env['GEMINI_CLI_HOME'] ?? os.homedir();
const globalGeminiDir = path.join(homeDir, '.gemini');
await fs.mkdir(path.join(inboxDir, 'private'), { recursive: true });
await fs.mkdir(path.join(inboxDir, 'global'), { recursive: true });
const privateTarget = path.join(memoryDir, 'verify-memory.md');
await fs.writeFile(
path.join(inboxDir, 'private', 'verify-memory.patch'),
[
`--- /dev/null`,
`+++ ${privateTarget}`,
`@@ -0,0 +1,3 @@`,
`+# Project Memory Candidate`,
`+`,
`+Future agents should remember that this project verifies memory changes with \`npm run verify:memory\`.`,
``,
].join('\n'),
);
const globalTarget = path.join(globalGeminiDir, 'GEMINI.md');
await fs.writeFile(
path.join(inboxDir, 'global', 'reply-style.patch'),
[
`--- /dev/null`,
`+++ ${globalTarget}`,
`@@ -0,0 +1,1 @@`,
`+User prefers concise Chinese architecture plans.`,
``,
].join('\n'),
);
return {
turn_count: 3,
duration_ms: 25,
terminate_reason: 'GOAL',
};
}),
}),
);
});
afterEach(async () => {
vi.unstubAllEnvs();
while (fixtures.length > 0) {
const fixture = fixtures.pop();
if (fixture) {
await fs.rm(fixture.rootDir, { recursive: true, force: true });
}
}
});
function autoMemoryEval(name: string, fn: () => Promise<void>): void {
runEval(
'USUALLY_PASSES',
{
suiteName: 'auto-memory-modes',
suiteType: 'component-level',
name,
timeout: 30000,
},
fn,
40000,
);
}
async function createFixture(): Promise<Fixture> {
const rootDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'gemini-auto-memory-eval-'),
);
const homeDir = path.join(rootDir, 'home');
const targetDir = path.join(rootDir, 'workspace');
const projectTempDir = path.join(rootDir, 'project-temp');
const memoryDir = path.join(projectTempDir, 'memory');
const skillsDir = path.join(memoryDir, 'skills');
await fs.mkdir(homeDir, { recursive: true });
await fs.mkdir(targetDir, { recursive: true });
await fs.mkdir(path.join(projectTempDir, 'chats'), { recursive: true });
vi.stubEnv('GEMINI_CLI_HOME', homeDir);
const config: MockMemoryConfig = {
storage: {
getProjectMemoryDir: () => memoryDir,
getProjectMemoryTempDir: () => memoryDir,
getProjectSkillsMemoryDir: () => skillsDir,
getProjectTempDir: () => projectTempDir,
getProjectRoot: () => targetDir,
},
getTargetDir: () => targetDir,
getToolRegistry: () => ({}),
getGeminiClient: () => ({}),
getSkillManager: () => ({ getSkills: () => [] }),
isAutoMemoryEnabled: () => true,
modelConfigService: {
registerRuntimeModelConfig: vi.fn(),
},
sandboxManager: undefined,
};
const fixture = {
rootDir,
homeDir,
targetDir,
projectTempDir,
memoryDir,
skillsDir,
config,
};
fixtures.push(fixture);
return fixture;
}
async function seedSession(
fixture: Fixture,
sessionId: string,
): Promise<string> {
const sessionFilePath = path.join(
fixture.projectTempDir,
'chats',
`${SESSION_FILE_PREFIX}2026-04-20T10-00-${sessionId}.json`,
);
const oldTimestamp = new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString();
const messages = Array.from({ length: 20 }, (_, index) => ({
id: `m${index + 1}`,
timestamp: oldTimestamp,
type: index % 2 === 0 ? 'user' : 'gemini',
content: [
{
text:
index % 2 === 0
? 'For this project, durable memory changes are verified with `npm run verify:memory`.'
: 'Acknowledged.',
},
],
}));
await fs.writeFile(
sessionFilePath,
[
{
sessionId,
projectHash: 'auto-memory-eval',
summary: 'Capture durable auto memory routing behavior',
startTime: oldTimestamp,
lastUpdated: oldTimestamp,
kind: 'main',
},
...messages,
]
.map((record) => JSON.stringify(record))
.join('\n') + '\n',
);
return sessionFilePath;
}
async function expectSeedSessionEligible(
fixture: Fixture,
sessionId: string,
): Promise<void> {
const { buildSessionIndex } = await import(
'../packages/core/src/services/memoryService.js'
);
const { newSessionIds } = await buildSessionIndex(
path.join(fixture.projectTempDir, 'chats'),
{ runs: [] },
);
expect(newSessionIds).toContain(sessionId);
}
async function readRun(fixture: Fixture): Promise<AutoMemoryRunSnapshot> {
const statePath = path.join(fixture.memoryDir, '.extraction-state.json');
let raw: string;
try {
raw = await fs.readFile(statePath, 'utf-8');
} catch (error) {
let memoryEntries = '(memory dir missing)';
try {
memoryEntries = (await fs.readdir(fixture.memoryDir, { recursive: true }))
.map(String)
.join('\n');
} catch {
// Leave default diagnostic.
}
throw new Error(
[
`Expected extraction state at ${statePath}.`,
`LocalAgentExecutor.create calls: ${mocks.localAgentCreate.mock.calls.length}`,
`Memory dir entries:\n${memoryEntries}`,
`Debug log:\n${evalState.debugLines.join('\n')}`,
].join('\n'),
{ cause: error },
);
}
const state = JSON.parse(raw) as {
runs?: AutoMemoryRunSnapshot[];
};
const run = state.runs?.at(-1);
if (!run) {
throw new Error('Expected an auto memory extraction run to be recorded');
}
return run;
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
describe('Auto Memory inbox routing', () => {
autoMemoryEval(
'every memory patch lands in .inbox/<kind>/ for review and active files stay untouched',
async () => {
const { startMemoryService } = await import(
'../packages/core/src/services/memoryService.js'
);
const fixture = await createFixture();
evalState.sessionFilePath = await seedSession(
fixture,
'inbox-routing-session',
);
await expectSeedSessionEligible(fixture, 'inbox-routing-session');
await startMemoryService(fixture.config as never);
const privatePatchPath = path.join(
fixture.memoryDir,
'.inbox',
'private',
'verify-memory.patch',
);
const globalPatchPath = path.join(
fixture.memoryDir,
'.inbox',
'global',
'reply-style.patch',
);
const activePrivateMemoryPath = path.join(
fixture.memoryDir,
'verify-memory.md',
);
const activeGlobalMemoryPath = path.join(
fixture.homeDir,
'.gemini',
'GEMINI.md',
);
const run = await readRun(fixture);
// Both patches were written to the inbox.
await expect(fs.readFile(privatePatchPath, 'utf-8')).resolves.toContain(
'npm run verify:memory',
);
await expect(fs.readFile(globalPatchPath, 'utf-8')).resolves.toContain(
'concise Chinese architecture plans',
);
// No active file was touched — every patch must be reviewed manually.
expect(await fileExists(activePrivateMemoryPath)).toBe(false);
expect(await fileExists(activeGlobalMemoryPath)).toBe(false);
// Run state records both patches as candidates and zero applied files.
expect(run.memoryFilesUpdated ?? []).toEqual([]);
expect(run.memoryCandidatesCreated ?? []).toEqual(
expect.arrayContaining([
path.relative(fixture.memoryDir, privatePatchPath),
path.relative(fixture.memoryDir, globalPatchPath),
]),
);
},
);
});
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"workspaces": [
"packages/*"
],
@@ -18077,7 +18077,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -18206,7 +18206,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18354,7 +18354,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18665,7 +18665,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18680,7 +18680,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18711,7 +18711,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18743,7 +18743,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-preview.2"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -0,0 +1,173 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Task } from './task.js';
import {
MessageBusType,
CoreToolCallStatus,
type Config,
type MessageBus,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { RequestContext } from '@a2a-js/sdk/server';
describe('Task Race Condition', () => {
let mockConfig: Config;
let messageBus: MessageBus;
beforeEach(() => {
messageBus = {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
publish: vi.fn(),
} as unknown as MessageBus;
mockConfig = createMockConfig({
messageBus,
}) as Config;
});
it('should not hang when multiple tool confirmations are processed while waiting', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register two tools as scheduled
task['_registerToolCall']('tool-1', 'scheduled');
task['_registerToolCall']('tool-2', 'scheduled');
// 2. Both transition to awaiting_approval
const updateHandler = (messageBus.subscribe as Mock).mock.calls.find(
(c: unknown[]) => c[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
updateHandler({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId: 'task-id',
toolCalls: [
{
request: { callId: 'tool-1', name: 't1' },
status: CoreToolCallStatus.AwaitingApproval,
correlationId: 'corr-1',
confirmationDetails: { type: 'info' },
},
{
request: { callId: 'tool-2', name: 't2' },
status: CoreToolCallStatus.AwaitingApproval,
correlationId: 'corr-2',
confirmationDetails: { type: 'info' },
},
],
});
// 3. Confirm Tool 1. This makes isAwaitingApprovalOnly() return false.
for await (const _ of task.acceptUserMessage(
{
userMessage: {
parts: [
{
kind: 'data',
data: { callId: 'tool-1', outcome: 'proceed_once' },
},
],
},
} as unknown as RequestContext,
new AbortController().signal,
)) {
// consume generator
}
// 4. Start waiting. This should now block because Tool 1 is confirmed (so we are waiting for its execution).
const waitPromise = task.waitForPendingTools();
// 5. Confirm Tool 2 while waiting.
for await (const _ of task.acceptUserMessage(
{
userMessage: {
parts: [
{
kind: 'data',
data: { callId: 'tool-2', outcome: 'proceed_once' },
},
],
},
} as unknown as RequestContext,
new AbortController().signal,
)) {
// consume generator
}
// 6. Both tools complete successfully
updateHandler({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId: 'task-id',
toolCalls: [
{
request: { callId: 'tool-1', name: 't1' },
status: CoreToolCallStatus.Success,
response: { responseParts: [] },
},
{
request: { callId: 'tool-2', name: 't2' },
status: CoreToolCallStatus.Success,
response: { responseParts: [] },
},
],
});
// 7. Verify that the original waitPromise resolves.
await expect(waitPromise).resolves.toBeUndefined();
});
it('should reject waitForPendingTools when tools are cancelled', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register a tool
task['_registerToolCall']('tool-1', 'scheduled');
// 2. Start waiting
const waitPromise = task.waitForPendingTools();
// 3. Cancel pending tools
task.cancelPendingTools('User requested cancellation');
// 4. Verify waitPromise rejects with the reason
await expect(waitPromise).rejects.toThrow('User requested cancellation');
});
it('should handle concurrent tool scheduling correctly', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register a tool and start waiting
task['_registerToolCall']('tool-1', 'scheduled');
const waitPromise = task.waitForPendingTools();
// 2. Schedule another tool concurrently (e.g. from a secondary user message)
// This should NOT resolve the current waitPromise until both are done
await task.scheduleToolCalls(
[{ callId: 'tool-2', name: 't2', args: {} }],
new AbortController().signal,
);
expect(task['pendingToolCalls'].size).toBe(2);
// 3. Resolve tool 1
task['_resolveToolCall']('tool-1');
// 4. Verify waitPromise is still pending
let resolved = false;
waitPromise.then(() => (resolved = true));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(resolved).toBe(false);
// 5. Resolve tool 2
task['_resolveToolCall']('tool-2');
// 6. Now it should resolve
await expect(waitPromise).resolves.toBeUndefined();
});
});
@@ -12,6 +12,7 @@ import {
ApprovalMode,
Scheduler,
type MessageBus,
type ToolLiveOutput,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
@@ -66,6 +67,7 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
expect(mockEventBus.publish).toHaveBeenCalledWith(
@@ -106,6 +108,7 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Simulate A2A client confirmation
@@ -148,7 +151,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 +181,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 +226,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 +270,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 +313,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 +356,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 +403,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 +450,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 +485,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 +569,7 @@ describe('Task Event-Driven Scheduler', () => {
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
schedulerId: 'task-id',
});
// Confirm first tool call
@@ -572,6 +609,74 @@ describe('Task Event-Driven Scheduler', () => {
);
});
it('should handle multi-turn tool resolution correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig);
task['_registerToolCall']('1', 'scheduled');
task['_registerToolCall']('2', 'scheduled');
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
// Turn 1: Resolve tool 1
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
request: { callId: '1', name: 't1' },
status: 'success',
response: { responseParts: [] },
},
],
schedulerId: 'task-id',
});
expect(task['pendingToolCalls'].size).toBe(1);
expect(task['pendingToolCalls'].has('2')).toBe(true);
// Turn 2: Resolve tool 2
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
request: { callId: '2', name: 't2' },
status: 'success',
response: { responseParts: [] },
},
],
schedulerId: 'task-id',
});
expect(task['pendingToolCalls'].size).toBe(0);
});
it('should handle subagent progress events from the scheduler', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
// Trigger _schedulerOutputUpdate with subagent progress
task['_schedulerOutputUpdate']('tool-1', {
isSubagentProgress: true,
agentName: 'researcher',
recentActivity: [],
} as ToolLiveOutput);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'artifact-update',
artifact: expect.objectContaining({
parts: [
expect.objectContaining({
text: expect.stringContaining('researcher'),
}),
],
}),
}),
);
});
it('should wait for executing tools before transitioning to input-required state', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
@@ -600,6 +705,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 +727,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);
});
});
});
+74 -49
View File
@@ -11,6 +11,7 @@ import {
GeminiEventType,
ToolConfirmationOutcome,
ApprovalMode,
CoreToolCallStatus,
getAllMCPServerStatuses,
MCPServerStatus,
isNodeError,
@@ -51,6 +52,7 @@ import type {
Artifact,
} from '@a2a-js/sdk';
import { v4 as uuidv4 } from 'uuid';
import { EventEmitter } from 'node:events';
import { logger } from '../utils/logger.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
@@ -95,12 +97,11 @@ 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?: {
resolve: () => void;
reject: (reason?: Error) => void;
};
private toolUpdateEmitter = new EventEmitter();
private cancellationError?: Error;
private constructor(
id: string,
@@ -121,7 +122,6 @@ export class Task {
this.taskState = 'submitted';
this.eventBus = eventBus;
this.completedToolCalls = [];
this._resetToolCompletionPromise();
this.autoExecute = autoExecute;
this.config.setFallbackModelHandler(
// For a2a-server, we want to automatically switch to the fallback model
@@ -176,22 +176,9 @@ export class Task {
return metadata;
}
private _resetToolCompletionPromise(): void {
this.toolCompletionPromise = new Promise((resolve, reject) => {
this.toolCompletionNotifier = { resolve, reject };
});
// If there are no pending calls when reset, resolve immediately.
if (this.pendingToolCalls.size === 0 && this.toolCompletionNotifier) {
this.toolCompletionNotifier.resolve();
}
}
private _registerToolCall(toolCallId: string, status: string): void {
const wasEmpty = this.pendingToolCalls.size === 0;
this.pendingToolCalls.set(toolCallId, status);
if (wasEmpty) {
this._resetToolCompletionPromise();
}
this.toolUpdateEmitter.emit('update');
logger.info(
`[Task] Registered tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`,
);
@@ -200,23 +187,47 @@ export class Task {
private _resolveToolCall(toolCallId: string): void {
if (this.pendingToolCalls.has(toolCallId)) {
this.pendingToolCalls.delete(toolCallId);
this.toolUpdateEmitter.emit('update');
logger.info(
`[Task] Resolved tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`,
);
if (this.pendingToolCalls.size === 0 && this.toolCompletionNotifier) {
this.toolCompletionNotifier.resolve();
}
}
}
async waitForPendingTools(): Promise<void> {
private isAwaitingApprovalOnly(): boolean {
if (this.pendingToolCalls.size === 0) {
return Promise.resolve();
return false;
}
for (const [callId, status] of this.pendingToolCalls.entries()) {
if (
status !== CoreToolCallStatus.AwaitingApproval ||
this.toolsAlreadyConfirmed.has(callId)
) {
return false;
}
}
return true;
}
async waitForPendingTools(): Promise<void> {
while (this.pendingToolCalls.size > 0 && !this.isAwaitingApprovalOnly()) {
if (this.cancellationError) {
const error = this.cancellationError;
this.cancellationError = undefined;
throw error;
}
logger.info(
`[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`,
);
await new Promise((resolve) =>
this.toolUpdateEmitter.once('update', resolve),
);
}
if (this.cancellationError) {
const error = this.cancellationError;
this.cancellationError = undefined;
throw error;
}
logger.info(
`[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`,
);
await this.toolCompletionPromise;
}
cancelPendingTools(reason: string): void {
@@ -225,15 +236,13 @@ export class Task {
`[Task] Cancelling all ${this.pendingToolCalls.size} pending tool calls. Reason: ${reason}`,
);
}
if (this.toolCompletionNotifier) {
this.toolCompletionNotifier.reject(new Error(reason));
}
this.cancellationError = new Error(reason);
this.pendingToolCalls.clear();
this.pendingCorrelationIds.clear();
this.toolsAlreadyConfirmed.clear();
this.scheduler.cancelAll();
// Reset the promise for any future operations, ensuring it's in a clean state.
this._resetToolCompletionPromise();
this.toolUpdateEmitter.emit('update');
}
private _createTextMessage(
@@ -413,7 +422,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 +438,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 +448,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 +471,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 +514,8 @@ export class Task {
);
this.eventBus?.publish(statusUpdate);
}
return hasChanged;
}
private checkInputRequiredState(): void {
@@ -508,12 +528,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;
}
}
@@ -536,8 +558,8 @@ export class Task {
// Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream.
// The IDE client will open a new stream with the confirmation reply.
if (!wasAlreadyInputRequired && this.toolCompletionNotifier) {
this.toolCompletionNotifier.resolve();
if (!wasAlreadyInputRequired) {
this.toolUpdateEmitter.emit('update');
}
}
}
@@ -574,8 +596,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,
@@ -895,6 +923,7 @@ export class Task {
const outcomeString = part.data['outcome'];
this.toolsAlreadyConfirmed.add(callId);
this.toolUpdateEmitter.emit('update');
let confirmationOutcome: ToolConfirmationOutcome | undefined;
@@ -1108,10 +1137,6 @@ export class Task {
if (confirmationHandled) {
anyConfirmationHandled = true;
// If a confirmation was handled, the scheduler will now run the tool (or cancel it).
// We resolve the toolCompletionPromise manually in checkInputRequiredState
// to break the original execution loop, so we must reset it here so the
// new loop correctly awaits the tool's final execution.
this._resetToolCompletionPromise();
// We don't send anything to the LLM for this part.
// The subsequent tool execution will eventually lead to resolveToolCall.
continue;
+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 () => {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-preview.2"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+21 -4
View File
@@ -6,6 +6,7 @@
import {
addMemory,
listInboxMemoryPatches,
listInboxSkills,
listInboxPatches,
listMemoryFiles,
@@ -129,7 +130,7 @@ export class AddMemoryCommand implements Command {
export class InboxMemoryCommand implements Command {
readonly name = 'memory inbox';
readonly description =
'Lists skills extracted from past sessions that are pending review.';
'Lists memory items extracted from past sessions that are pending review.';
async execute(
context: CommandContext,
@@ -142,12 +143,17 @@ export class InboxMemoryCommand implements Command {
};
}
const [skills, patches] = await Promise.all([
const [skills, patches, memoryPatches] = await Promise.all([
listInboxSkills(context.agentContext.config),
listInboxPatches(context.agentContext.config),
listInboxMemoryPatches(context.agentContext.config),
]);
if (skills.length === 0 && patches.length === 0) {
if (
skills.length === 0 &&
patches.length === 0 &&
memoryPatches.length === 0
) {
return { name: this.name, data: 'No items in inbox.' };
}
@@ -165,8 +171,19 @@ export class InboxMemoryCommand implements Command {
: '';
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
}
for (const memoryPatch of memoryPatches) {
const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
const date = memoryPatch.extractedAt
? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
: '';
const sourceCount = memoryPatch.sourceFiles.length;
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
lines.push(
`- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
);
}
const total = skills.length + patches.length;
const total = skills.length + patches.length + memoryPatches.length;
return {
name: this.name,
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
@@ -20,8 +20,11 @@ import {
getScopedEnvContents,
type ExtensionSetting,
} from '../../config/extensions/extensionSettings.js';
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
import prompts from 'prompts';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
vi.hoisted(() => {
@@ -84,7 +87,9 @@ describe('extensions configure command', () => {
vi.spyOn(debugLogger, 'error');
vi.clearAllMocks();
tempWorkspaceDir = fs.mkdtempSync('gemini-cli-test-workspace');
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
// Default behaviors
mockLoadSettings.mockReturnValue({ merged: {} });
@@ -94,7 +99,8 @@ describe('extensions configure command', () => {
);
});
afterEach(() => {
afterEach(async () => {
await cleanupTmpDir(tempWorkspaceDir);
vi.restoreAllMocks();
});
@@ -10,6 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import { createTestMergedSettings } from './settings.js';
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
import {
loadAgentsFromDirectory,
loadSkillsFromDir,
@@ -87,8 +88,9 @@ describe('ExtensionManager Settings Scope', () => {
);
});
afterEach(() => {
// Clean up files if needed, or rely on temp dir cleanup
afterEach(async () => {
await cleanupTmpDir(currentTempHome);
await cleanupTmpDir(tempWorkspace);
vi.clearAllMocks();
});
@@ -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;
+11 -1
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',
@@ -2410,7 +2420,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description:
'Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox.',
'Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it.',
showInDialog: true,
},
generalistProfile: {
@@ -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,
+62 -1
View File
@@ -100,7 +100,7 @@ import { type LoadedSettings } from '../config/settings.js';
import { createMockSettings } from '../test-utils/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { StreamingState } from './types.js';
import { StreamingState, MessageType } from './types.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIActionsContext,
@@ -3576,4 +3576,65 @@ describe('AppContainer State Management', () => {
unmount();
});
});
describe('Compression Queuing', () => {
beforeEach(async () => {
const { checkPermissions } = await import(
'./hooks/atCommandProcessor.js'
);
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'isModelSteeringEnabled').mockReturnValue(true);
const actual = await vi.importActual('./hooks/useMessageQueue.js');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { useMessageQueue: realUseMessageQueue } = actual as any;
mockedUseMessageQueue.mockImplementation(realUseMessageQueue);
// Start compression by mocking pendingHistoryItems to include a pending compression
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
compressionStatus: null,
},
},
],
}));
});
it('queues messages during compression instead of handling as steering hints', async () => {
const { unmount } = await act(async () => renderAppContainer());
// Verify state isolation
expect(capturedUIState.streamingState).toBe(StreamingState.Idle);
// Submit a message
await act(async () =>
capturedUIActions.handleFinalSubmit('follow up message'),
);
// Verify it was queued, not submitted as steering hint
expect(capturedUIState.messageQueue).toContain('follow up message');
unmount();
});
it('executes slash commands immediately during compression', async () => {
const { unmount } = await act(async () => renderAppContainer());
// Submit a slash command
await act(async () => capturedUIActions.handleFinalSubmit('/help'));
// Verify it was NOT queued
expect(capturedUIState.messageQueue).not.toContain('/help');
unmount();
});
});
});
+21 -2
View File
@@ -1310,6 +1310,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { isMcpReady } = useMcpStatus(config);
const isCompressing = useMemo(
() =>
pendingHistoryItems.some(
(item) =>
item.type === MessageType.COMPRESSION && item.compression.isPending,
),
[pendingHistoryItems],
);
const {
messageQueue,
addMessage,
@@ -1321,6 +1330,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
streamingState,
submitQuery,
isMcpReady,
isCompressing,
});
cancelHandlerRef.current = useCallback(
@@ -1415,7 +1425,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
if (
(isSlash && isConfigInitialized) ||
(!isCompressing && isIdle && isMcpOrConfigReady)
) {
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -1438,7 +1451,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (isIdle && !isMcpOrConfigReady && messageQueue.length === 0) {
if (
isIdle &&
!isCompressing &&
!isMcpOrConfigReady &&
messageQueue.length === 0
) {
coreEvents.emitFeedback(
'info',
!isConfigInitialized
@@ -1458,6 +1476,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
slashCommands,
isMcpReady,
streamingState,
isCompressing,
messageQueue.length,
pendingHistoryItems,
config,
@@ -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(),
);
},
};
@@ -42,6 +42,7 @@ describe('compressCommand', () => {
},
};
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
@@ -62,6 +63,7 @@ describe('compressCommand', () => {
mockTryCompressChat.mockResolvedValue(compressedResult);
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.setPendingItem).toHaveBeenNthCalledWith(1, {
type: MessageType.COMPRESSION,
@@ -98,6 +100,7 @@ describe('compressCommand', () => {
mockTryCompressChat.mockResolvedValue(null);
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -114,6 +117,7 @@ describe('compressCommand', () => {
mockTryCompressChat.mockRejectedValue(error);
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -128,6 +132,7 @@ describe('compressCommand', () => {
it('should clear the pending item in a finally block', async () => {
mockTryCompressChat.mockRejectedValue(new Error('some error'));
await compressCommand.action!(context, '');
await new Promise((r) => setTimeout(r, 0));
expect(context.ui.setPendingItem).toHaveBeenCalledWith(null);
});
+38 -35
View File
@@ -36,48 +36,51 @@ export const compressCommand: SlashCommand = {
},
};
try {
ui.setPendingItem(pendingMessage);
const promptId = `compress-${Date.now()}`;
const compressed =
await context.services.agentContext?.geminiClient?.tryCompressChat(
promptId,
true,
);
if (compressed) {
ui.addItem(
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
ui.setPendingItem(pendingMessage);
void (async () => {
try {
const promptId = `compress-${Date.now()}`;
const compressed =
await context.services.agentContext?.geminiClient?.tryCompressChat(
promptId,
true,
);
if (compressed) {
ui.addItem(
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
},
} as HistoryItemCompression,
Date.now(),
);
} else {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Failed to compress chat history.',
},
} as HistoryItemCompression,
Date.now(),
);
} else {
Date.now(),
);
}
} catch (e) {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Failed to compress chat history.',
text: `Failed to compress chat history: ${
e instanceof Error ? e.message : String(e)
}`,
},
Date.now(),
);
} finally {
ui.setPendingItem(null);
}
} catch (e) {
ui.addItem(
{
type: MessageType.ERROR,
text: `Failed to compress chat history: ${
e instanceof Error ? e.message : String(e)
}`,
},
Date.now(),
);
} finally {
ui.setPendingItem(null);
}
})();
},
};
@@ -18,7 +18,7 @@ import {
type SlashCommand,
type SlashCommandActionReturn,
} from './types.js';
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
import { InboxDialog } from '../components/InboxDialog.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
@@ -156,13 +156,16 @@ export const memoryCommand: SlashCommand = {
return {
type: 'custom_dialog',
component: React.createElement(SkillInboxDialog, {
component: React.createElement(InboxDialog, {
config,
onClose: () => context.ui.removeComponent(),
onReloadSkills: async () => {
await config.reloadSkills();
context.ui.reloadCommands();
},
onReloadMemory: async () => {
await refreshMemory(config);
},
}),
};
},
@@ -6,19 +6,32 @@
import { act } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config, InboxSkill, InboxPatch } from '@google/gemini-cli-core';
import type {
Config,
InboxSkill,
InboxPatch,
InboxMemoryPatch,
} from '@google/gemini-cli-core';
import {
dismissInboxSkill,
dismissInboxMemoryPatch,
listInboxSkills,
listInboxPatches,
listInboxMemoryPatches,
moveInboxSkill,
applyInboxPatch,
dismissInboxPatch,
applyInboxMemoryPatch,
isProjectSkillPatchTarget,
} from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { SkillInboxDialog } from './SkillInboxDialog.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 =
@@ -27,11 +40,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...original,
dismissInboxSkill: vi.fn(),
dismissInboxMemoryPatch: vi.fn(),
listInboxSkills: vi.fn(),
listInboxPatches: vi.fn(),
listInboxMemoryPatches: vi.fn(),
moveInboxSkill: vi.fn(),
applyInboxPatch: vi.fn(),
dismissInboxPatch: vi.fn(),
applyInboxMemoryPatch: vi.fn(),
isProjectSkillPatchTarget: vi.fn(),
getErrorMessage: vi.fn((error: unknown) =>
error instanceof Error ? error.message : String(error),
@@ -41,10 +57,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const mockListInboxSkills = vi.mocked(listInboxSkills);
const mockListInboxPatches = vi.mocked(listInboxPatches);
const mockListInboxMemoryPatches = vi.mocked(listInboxMemoryPatches);
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
const mockApplyInboxPatch = vi.mocked(applyInboxPatch);
const mockDismissInboxPatch = vi.mocked(dismissInboxPatch);
const mockApplyInboxMemoryPatch = vi.mocked(applyInboxMemoryPatch);
const mockDismissInboxMemoryPatch = vi.mocked(dismissInboxMemoryPatch);
const mockIsProjectSkillPatchTarget = vi.mocked(isProjectSkillPatchTarget);
const inboxSkill: InboxSkill = {
@@ -76,6 +95,27 @@ const inboxPatch: InboxPatch = {
extractedAt: '2025-01-20T14:00:00Z',
};
const inboxMemoryPatch: InboxMemoryPatch = {
kind: 'private',
relativePath: 'private',
name: 'Private memory',
sourceFiles: ['update-memory.patch'],
entries: [
{
targetPath: '/home/user/.gemini/tmp/project/memory/MEMORY.md',
isNewFile: false,
diffContent: [
'--- /home/user/.gemini/tmp/project/memory/MEMORY.md',
'+++ /home/user/.gemini/tmp/project/memory/MEMORY.md',
'@@ -1,1 +1,1 @@',
'-old',
'+use focused tests',
].join('\n'),
},
],
extractedAt: '2025-01-21T10:00:00Z',
};
const workspacePatch: InboxPatch = {
fileName: 'workspace-update.patch',
name: 'workspace-update',
@@ -137,11 +177,12 @@ const windowsGlobalPatch: InboxPatch = {
],
};
describe('SkillInboxDialog', () => {
describe('InboxDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockListInboxSkills.mockResolvedValue([inboxSkill]);
mockListInboxPatches.mockResolvedValue([]);
mockListInboxMemoryPatches.mockResolvedValue([]);
mockMoveInboxSkill.mockResolvedValue({
success: true,
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
@@ -158,6 +199,14 @@ describe('SkillInboxDialog', () => {
success: true,
message: 'Dismissed "update-docs.patch" from inbox.',
});
mockApplyInboxMemoryPatch.mockResolvedValue({
success: true,
message: 'Applied memory patch to 1 file.',
});
mockDismissInboxMemoryPatch.mockResolvedValue({
success: true,
message: 'Dismissed 1 private memory patch from inbox.',
});
mockIsProjectSkillPatchTarget.mockImplementation(
async (targetPath: string, config: Config) => {
const projectSkillsDir = config.storage
@@ -176,6 +225,64 @@ describe('SkillInboxDialog', () => {
vi.unstubAllEnvs();
});
it('reviews and applies memory patches', async () => {
mockListInboxSkills.mockResolvedValue([]);
mockListInboxMemoryPatches.mockResolvedValue([inboxMemoryPatch]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const onReloadMemory = vi.fn().mockResolvedValue(undefined);
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn()}
onReloadMemory={onReloadMemory}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('Private memory');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Review');
expect(frame).toMatch(/source patch/);
});
// Memory patches default to Dismiss as the highlighted action so a stray
// Enter cannot apply durable changes. Arrow-down to reach Apply, then
// press Enter to confirm.
await act(async () => {
stdin.write('\u001B[B'); // arrow down → Apply
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
// Aggregate apply: relativePath equals the kind name.
expect(mockApplyInboxMemoryPatch).toHaveBeenCalledWith(
config,
'private',
'private',
);
expect(onReloadMemory).toHaveBeenCalled();
});
unmount();
});
it('disables the project destination when the workspace is untrusted', async () => {
const config = {
isTrustedFolder: vi.fn().mockReturnValue(false),
@@ -183,7 +290,7 @@ describe('SkillInboxDialog', () => {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
@@ -228,7 +335,7 @@ describe('SkillInboxDialog', () => {
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -276,7 +383,7 @@ describe('SkillInboxDialog', () => {
.mockRejectedValue(new Error('reload hook failed'));
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
@@ -316,6 +423,83 @@ describe('SkillInboxDialog', () => {
unmount();
});
it('preserves the highlighted row after Esc-ing back from a sub-phase', async () => {
// Reproduces the bug where pressing Esc from the apply dialog re-rendered
// the list with focus jumped back to row 0 instead of staying on the row
// the user was on.
const secondSkill: InboxSkill = {
...inboxSkill,
dirName: 'second-skill',
name: 'Second Skill',
};
mockListInboxSkills.mockResolvedValue([inboxSkill, secondSkill]);
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Inbox Skill');
expect(frame).toContain('Second Skill');
});
// Arrow down to the second row.
await act(async () => {
stdin.write('\x1b[B');
await waitUntilReady();
});
// Enter the second row's preview.
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Review new skill');
expect(frame).toContain('Second Skill');
});
// Esc back to list.
await act(async () => {
stdin.write('\x1b');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Inbox Skill');
expect(frame).toContain('Second Skill');
});
// Re-enter (no arrow keys this time). The active row must still be the
// SECOND skill, not the first — which is what the bug reproduced before.
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Review new skill');
// The preview header echoes the highlighted skill's name.
expect(frame).toContain('Second Skill');
});
unmount();
});
describe('patch support', () => {
it('shows patches alongside skills with section headers', async () => {
mockListInboxPatches.mockResolvedValue([inboxPatch]);
@@ -328,7 +512,7 @@ describe('SkillInboxDialog', () => {
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -360,7 +544,7 @@ describe('SkillInboxDialog', () => {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -401,7 +585,7 @@ describe('SkillInboxDialog', () => {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
@@ -449,7 +633,7 @@ describe('SkillInboxDialog', () => {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -494,7 +678,7 @@ describe('SkillInboxDialog', () => {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -538,7 +722,7 @@ describe('SkillInboxDialog', () => {
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
@@ -593,7 +777,7 @@ describe('SkillInboxDialog', () => {
} as unknown as Config;
const { lastFrame, unmount } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -628,7 +812,7 @@ describe('SkillInboxDialog', () => {
const { lastFrame, stdin, unmount, waitUntilReady } = await act(
async () =>
renderWithProviders(
<SkillInboxDialog
<InboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
@@ -656,5 +840,332 @@ describe('SkillInboxDialog', () => {
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
@@ -326,6 +326,36 @@ describe('SettingsDialog', () => {
});
unmount();
});
it('should render the bottom border correctly when height is constrained', async () => {
const settings = createMockSettings();
const onSelect = vi.fn();
const constrainedHeight = 15;
const renderResult = await renderDialog(settings, onSelect, {
availableTerminalHeight: constrainedHeight,
});
await renderResult.waitUntilReady();
await waitFor(() => {
const output = renderResult.lastFrame();
const lines = output.trim().split('\n');
// Verify height constraint
expect(lines.length).toBeLessThanOrEqual(constrainedHeight);
// Verify bottom border existence in the last line of the output
const lastLine = lines[lines.length - 1];
// 'round' border characters: ─, ╰, ╯
expect(lastLine).toMatch(/[─╰╯]/);
});
// SVG snapshot ensures visual layout and border rendering are preserved
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
});
});
describe('Setting Descriptions', () => {
@@ -1,876 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Box, Text, useStdout } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import {
type Config,
type InboxSkill,
type InboxPatch,
type InboxSkillDestination,
getErrorMessage,
listInboxSkills,
listInboxPatches,
moveInboxSkill,
dismissInboxSkill,
applyInboxPatch,
dismissInboxPatch,
isProjectSkillPatchTarget,
} from '@google/gemini-cli-core';
type Phase = 'list' | 'skill-preview' | 'skill-action' | 'patch-preview';
type InboxItem =
| { type: 'skill'; skill: InboxSkill }
| { type: 'patch'; patch: InboxPatch; targetsProjectSkills: boolean }
| { type: 'header'; label: string };
interface DestinationChoice {
destination: InboxSkillDestination;
label: string;
description: string;
}
interface PatchAction {
action: 'apply' | 'dismiss';
label: string;
description: string;
}
const SKILL_DESTINATION_CHOICES: DestinationChoice[] = [
{
destination: 'global',
label: 'Global',
description: '~/.gemini/skills — available in all projects',
},
{
destination: 'project',
label: 'Project',
description: '.gemini/skills — available in this workspace',
},
];
interface SkillPreviewAction {
action: 'move' | 'dismiss';
label: string;
description: string;
}
const SKILL_PREVIEW_CHOICES: SkillPreviewAction[] = [
{
action: 'move',
label: 'Move',
description: 'Choose where to install this skill',
},
{
action: 'dismiss',
label: 'Dismiss',
description: 'Delete from inbox',
},
];
const PATCH_ACTION_CHOICES: PatchAction[] = [
{
action: 'apply',
label: 'Apply',
description: 'Apply patch and delete from inbox',
},
{
action: 'dismiss',
label: 'Dismiss',
description: 'Delete from inbox without applying',
},
];
function normalizePathForUi(filePath: string): string {
return path.posix.normalize(filePath.replaceAll('\\', '/'));
}
function getPathBasename(filePath: string): string {
const normalizedPath = normalizePathForUi(filePath);
const basename = path.posix.basename(normalizedPath);
return basename === '.' ? filePath : basename;
}
async function patchTargetsProjectSkills(
patch: InboxPatch,
config: Config,
): Promise<boolean> {
const entryTargetsProjectSkills = await Promise.all(
patch.entries.map((entry) =>
isProjectSkillPatchTarget(entry.targetPath, config),
),
);
return entryTargetsProjectSkills.some(Boolean);
}
/**
* Derives a bracketed origin tag from a skill file path,
* matching the existing [Built-in] convention in SkillsList.
*/
function getSkillOriginTag(filePath: string): string {
const normalizedPath = normalizePathForUi(filePath);
if (normalizedPath.includes('/bundle/')) {
return 'Built-in';
}
if (normalizedPath.includes('/extensions/')) {
return 'Extension';
}
if (normalizedPath.includes('/.gemini/skills/')) {
const homeDirs = [process.env['HOME'], process.env['USERPROFILE']]
.filter((homeDir): homeDir is string => Boolean(homeDir))
.map(normalizePathForUi);
if (
homeDirs.some((homeDir) =>
normalizedPath.startsWith(`${homeDir}/.gemini/skills/`),
)
) {
return 'Global';
}
return 'Workspace';
}
return '';
}
/**
* Creates a unified diff string representing a new file.
*/
function newFileDiff(filename: string, content: string): string {
const lines = content.split('\n');
const hunkLines = lines.map((l) => `+${l}`).join('\n');
return [
`--- /dev/null`,
`+++ ${filename}`,
`@@ -0,0 +1,${lines.length} @@`,
hunkLines,
].join('\n');
}
function formatDate(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return isoString;
}
}
interface SkillInboxDialogProps {
config: Config;
onClose: () => void;
onReloadSkills: () => Promise<void>;
}
export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
config,
onClose,
onReloadSkills,
}) => {
const keyMatchers = useKeyMatchers();
const { stdout } = useStdout();
const terminalWidth = stdout?.columns ?? 80;
const isTrustedFolder = config.isTrustedFolder();
const [phase, setPhase] = useState<Phase>('list');
const [items, setItems] = useState<InboxItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedItem, setSelectedItem] = useState<InboxItem | null>(null);
const [feedback, setFeedback] = useState<{
text: string;
isError: boolean;
} | null>(null);
// Load inbox skills and patches on mount
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [skills, patches] = await Promise.all([
listInboxSkills(config),
listInboxPatches(config),
]);
const patchItems = await Promise.all(
patches.map(async (patch): Promise<InboxItem> => {
let targetsProjectSkills = false;
try {
targetsProjectSkills = await patchTargetsProjectSkills(
patch,
config,
);
} catch {
targetsProjectSkills = false;
}
return {
type: 'patch',
patch,
targetsProjectSkills,
};
}),
);
if (!cancelled) {
const combined: InboxItem[] = [
...skills.map((skill): InboxItem => ({ type: 'skill', skill })),
...patchItems,
];
setItems(combined);
setLoading(false);
}
} catch {
if (!cancelled) {
setItems([]);
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [config]);
const getItemKey = useCallback(
(item: InboxItem): string =>
item.type === 'skill'
? `skill:${item.skill.dirName}`
: item.type === 'patch'
? `patch:${item.patch.fileName}`
: `header:${item.label}`,
[],
);
const listItems: Array<SelectionListItem<InboxItem>> = useMemo(() => {
const skills = items.filter((i) => i.type === 'skill');
const patches = items.filter((i) => i.type === 'patch');
const result: Array<SelectionListItem<InboxItem>> = [];
// Only show section headers when both types are present
const showHeaders = skills.length > 0 && patches.length > 0;
if (showHeaders) {
const header: InboxItem = { type: 'header', label: 'New Skills' };
result.push({
key: 'header:new-skills',
value: header,
disabled: true,
hideNumber: true,
});
}
for (const item of skills) {
result.push({ key: getItemKey(item), value: item });
}
if (showHeaders) {
const header: InboxItem = { type: 'header', label: 'Skill Updates' };
result.push({
key: 'header:skill-updates',
value: header,
disabled: true,
hideNumber: true,
});
}
for (const item of patches) {
result.push({ key: getItemKey(item), value: item });
}
return result;
}, [items, getItemKey]);
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
() =>
SKILL_DESTINATION_CHOICES.map((choice) => {
if (choice.destination === 'project' && !isTrustedFolder) {
return {
key: choice.destination,
value: {
...choice,
description:
'.gemini/skills — unavailable until this workspace is trusted',
},
disabled: true,
};
}
return {
key: choice.destination,
value: choice,
};
}),
[isTrustedFolder],
);
const selectedPatchTargetsProjectSkills = useMemo(() => {
if (!selectedItem || selectedItem.type !== 'patch') {
return false;
}
return selectedItem.targetsProjectSkills;
}, [selectedItem]);
const patchActionItems: Array<SelectionListItem<PatchAction>> = useMemo(
() =>
PATCH_ACTION_CHOICES.map((choice) => {
if (
choice.action === 'apply' &&
selectedPatchTargetsProjectSkills &&
!isTrustedFolder
) {
return {
key: choice.action,
value: {
...choice,
description:
'.gemini/skills — unavailable until this workspace is trusted',
},
disabled: true,
};
}
return {
key: choice.action,
value: choice,
};
}),
[isTrustedFolder, selectedPatchTargetsProjectSkills],
);
const skillPreviewItems: Array<SelectionListItem<SkillPreviewAction>> =
useMemo(
() =>
SKILL_PREVIEW_CHOICES.map((choice) => ({
key: choice.action,
value: choice,
})),
[],
);
const handleSelectItem = useCallback((item: InboxItem) => {
setSelectedItem(item);
setFeedback(null);
setPhase(item.type === 'skill' ? 'skill-preview' : 'patch-preview');
}, []);
const removeItem = useCallback(
(item: InboxItem) => {
setItems((prev) =>
prev.filter((i) => getItemKey(i) !== getItemKey(item)),
);
},
[getItemKey],
);
const handleSkillPreviewAction = useCallback(
(choice: SkillPreviewAction) => {
if (!selectedItem || selectedItem.type !== 'skill') return;
if (choice.action === 'move') {
setFeedback(null);
setPhase('skill-action');
return;
}
// Dismiss
setFeedback(null);
const skill = selectedItem.skill;
void (async () => {
try {
const result = await dismissInboxSkill(config, skill.dirName);
setFeedback({ text: result.message, isError: !result.success });
if (result.success) {
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
}
} catch (error) {
setFeedback({
text: `Failed to dismiss skill: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedItem, removeItem],
);
const handleSelectDestination = useCallback(
(choice: DestinationChoice) => {
if (!selectedItem || selectedItem.type !== 'skill') return;
const skill = selectedItem.skill;
if (choice.destination === 'project' && !config.isTrustedFolder()) {
setFeedback({
text: 'Project skills are unavailable until this workspace is trusted.',
isError: true,
});
return;
}
setFeedback(null);
void (async () => {
try {
const result = await moveInboxSkill(
config,
skill.dirName,
choice.destination,
);
setFeedback({ text: result.message, isError: !result.success });
if (!result.success) {
return;
}
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
try {
await onReloadSkills();
} catch (error) {
setFeedback({
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
isError: true,
});
}
} catch (error) {
setFeedback({
text: `Failed to install skill: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedItem, onReloadSkills, removeItem],
);
const handleSelectPatchAction = useCallback(
(choice: PatchAction) => {
if (!selectedItem || selectedItem.type !== 'patch') return;
const patch = selectedItem.patch;
if (
choice.action === 'apply' &&
!config.isTrustedFolder() &&
selectedItem.targetsProjectSkills
) {
setFeedback({
text: 'Project skill patches are unavailable until this workspace is trusted.',
isError: true,
});
return;
}
setFeedback(null);
void (async () => {
try {
let result: { success: boolean; message: string };
if (choice.action === 'apply') {
result = await applyInboxPatch(config, patch.fileName);
} else {
result = await dismissInboxPatch(config, patch.fileName);
}
setFeedback({ text: result.message, isError: !result.success });
if (!result.success) {
return;
}
removeItem(selectedItem);
setSelectedItem(null);
setPhase('list');
if (choice.action === 'apply') {
try {
await onReloadSkills();
} catch (error) {
setFeedback({
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
isError: true,
});
}
}
} catch (error) {
const operation =
choice.action === 'apply' ? 'apply patch' : 'dismiss patch';
setFeedback({
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedItem, onReloadSkills, removeItem],
);
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
if (phase === 'skill-action') {
setPhase('skill-preview');
setFeedback(null);
} else if (phase !== 'list') {
setPhase('list');
setSelectedItem(null);
setFeedback(null);
} else {
onClose();
}
return true;
}
return false;
},
{ isActive: true, priority: true },
);
if (loading) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
>
<Text>Loading inbox</Text>
</Box>
);
}
if (items.length === 0 && !feedback) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
>
<Text bold>Memory Inbox</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>No items in inbox.</Text>
</Box>
<DialogFooter primaryAction="Esc to close" cancelAction="" />
</Box>
);
}
// Border + paddingX account for 6 chars of width
const contentWidth = terminalWidth - 6;
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
width="100%"
>
{phase === 'list' && (
<>
<Text bold>
Memory Inbox ({items.length} item{items.length !== 1 ? 's' : ''})
</Text>
<Text color={theme.text.secondary}>
Extracted from past sessions. Select one to review.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<InboxItem>
items={listItems}
onSelect={handleSelectItem}
isFocused={true}
showNumbers={false}
showScrollArrows={true}
maxItemsToShow={8}
renderItem={(item, { titleColor }) => {
if (item.value.type === 'header') {
return (
<Box marginTop={1}>
<Text color={theme.text.secondary} bold>
{item.value.label}
</Text>
</Box>
);
}
if (item.value.type === 'skill') {
const skill = item.value.skill;
return (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{skill.name}
</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary} wrap="wrap">
{skill.description}
</Text>
{skill.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(skill.extractedAt)}
</Text>
)}
</Box>
</Box>
);
}
const patch = item.value.patch;
const fileNames = patch.entries.map((e) =>
getPathBasename(e.targetPath),
);
const origin = getSkillOriginTag(
patch.entries[0]?.targetPath ?? '',
);
return (
<Box flexDirection="column" minHeight={2}>
<Box flexDirection="row">
<Text color={titleColor} bold>
{patch.name}
</Text>
{origin && (
<Text color={theme.text.secondary}>
{` [${origin}]`}
</Text>
)}
</Box>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{fileNames.join(', ')}
</Text>
{patch.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(patch.extractedAt)}
</Text>
)}
</Box>
</Box>
);
}}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to select"
cancelAction="Esc to close"
/>
</>
)}
{phase === 'skill-preview' && selectedItem?.type === 'skill' && (
<>
<Text bold>{selectedItem.skill.name}</Text>
<Text color={theme.text.secondary}>
Review new skill before installing.
</Text>
{selectedItem.skill.content && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.secondary} bold>
SKILL.md
</Text>
<DiffRenderer
diffContent={newFileDiff(
'SKILL.md',
selectedItem.skill.content,
)}
filename="SKILL.md"
terminalWidth={contentWidth}
/>
</Box>
)}
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<SkillPreviewAction>
items={skillPreviewItems}
onSelect={handleSkillPreviewAction}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
{phase === 'skill-action' && selectedItem?.type === 'skill' && (
<>
<Text bold>Move &quot;{selectedItem.skill.name}&quot;</Text>
<Text color={theme.text.secondary}>
Choose where to install this skill.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<DestinationChoice>
items={destinationItems}
onSelect={handleSelectDestination}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
{phase === 'patch-preview' && selectedItem?.type === 'patch' && (
<>
<Text bold>{selectedItem.patch.name}</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
Review changes before applying.
</Text>
{(() => {
const origin = getSkillOriginTag(
selectedItem.patch.entries[0]?.targetPath ?? '',
);
return origin ? (
<Text color={theme.text.secondary}>{` [${origin}]`}</Text>
) : null;
})()}
</Box>
<Box flexDirection="column" marginTop={1}>
{selectedItem.patch.entries.map((entry, index) => (
<Box
key={`${selectedItem.patch.fileName}:${entry.targetPath}:${index}`}
flexDirection="column"
marginBottom={1}
>
<Text color={theme.text.secondary} bold>
{entry.targetPath}
</Text>
<DiffRenderer
diffContent={entry.diffContent}
filename={entry.targetPath}
terminalWidth={contentWidth}
/>
</Box>
))}
</Box>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<PatchAction>
items={patchActionItems}
onSelect={handleSelectPatchAction}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
</Box>
);
};
@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="275" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#878787" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="19" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs" font-weight="bold">&gt; Settings </text>
<text x="891" y="36" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="53" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#d7ffd7" textLength="864" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="891" y="70" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="87" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">╰─</text>
<rect x="36" y="85" width="9" height="17" fill="#ffffff" />
<text x="36" y="87" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">S</text>
<text x="45" y="87" fill="#afafaf" textLength="135" lengthAdjust="spacingAndGlyphs">earch to filter</text>
<text x="180" y="87" fill="#d7ffd7" textLength="702" lengthAdjust="spacingAndGlyphs">─────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="87" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="104" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="104" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="119" width="9" height="17" fill="#005f00" />
<text x="27" y="121" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="119" width="9" height="17" fill="#005f00" />
<rect x="45" y="119" width="72" height="17" fill="#005f00" />
<text x="45" y="121" fill="#d7ffd7" textLength="72" lengthAdjust="spacingAndGlyphs">Vim Mode</text>
<rect x="117" y="119" width="711" height="17" fill="#005f00" />
<rect x="828" y="119" width="45" height="17" fill="#005f00" />
<text x="828" y="121" fill="#d7ffd7" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="121" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="138" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="136" width="198" height="17" fill="#005f00" />
<text x="45" y="138" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">Enable Vim keybindings</text>
<text x="891" y="138" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="155" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="172" fill="#ffffff" textLength="882" lengthAdjust="spacingAndGlyphs"> Apply To </text>
<text x="891" y="172" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="187" width="9" height="17" fill="#005f00" />
<text x="27" y="189" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="187" width="9" height="17" fill="#005f00" />
<rect x="45" y="187" width="117" height="17" fill="#005f00" />
<text x="45" y="189" fill="#d7ffd7" textLength="117" lengthAdjust="spacingAndGlyphs">User Settings</text>
<rect x="162" y="187" width="711" height="17" fill="#005f00" />
<text x="891" y="189" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="206" fill="#afafaf" textLength="657" lengthAdjust="spacingAndGlyphs">(Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close)</text>
<text x="891" y="206" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#878787" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -46,6 +46,24 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Initial Rendering > should render the bottom border correctly when height is constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ > Settings │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
│ ╰─Search to filter─────────────────────────────────────────────────────────────────────────────╯ │
│ ▲ │
│ ● Vim Mode false │
│ ▼ Enable Vim keybindings │
│ │
│ Apply To │
│ ● User Settings │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
@@ -425,7 +425,7 @@ export function BaseSettingsDialog({
flexDirection="row"
padding={1}
width="100%"
height="100%"
maxHeight={availableHeight}
>
<Box flexDirection="column" flexGrow={1}>
{/* Title */}
@@ -1,130 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../../test-utils/render.js';
import { useTextBuffer } from './text-buffer.js';
import {
openFileInEditor,
EditorNotConfiguredError,
} from '../../utils/editorUtils.js';
import { coreEvents, CoreEvent } from '@google/gemini-cli-core';
import fs from 'node:fs';
vi.mock('node:fs', () => ({
default: {
mkdtempSync: vi.fn().mockReturnValue('/tmp/gemini-edit-123'),
writeFileSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('updated text'),
unlinkSync: vi.fn(),
rmdirSync: vi.fn(),
},
}));
vi.mock('node:os', () => ({
default: {
tmpdir: vi.fn().mockReturnValue('/tmp'),
},
}));
vi.mock('../../utils/editorUtils.js', () => ({
openFileInEditor: vi.fn(),
EditorNotConfiguredError: class extends Error {
constructor() {
super('No external editor configured');
this.name = 'EditorNotConfiguredError';
}
},
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
coreEvents: {
emit: vi.fn(),
emitFeedback: vi.fn(),
},
};
});
describe('useTextBuffer external editor', () => {
const viewport = { width: 80, height: 24 };
beforeEach(() => {
vi.clearAllMocks();
});
it('should emit feedback when openFileInEditor throws EditorNotConfiguredError', async () => {
vi.mocked(openFileInEditor).mockRejectedValue(
new EditorNotConfiguredError(),
);
const { result } = await renderHook(() =>
useTextBuffer({
initialText: 'some text',
viewport,
}),
);
await act(async () => {
await result.current.openInExternalEditor();
});
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
'No external editor configured. Please set your preferred editor in settings.',
);
expect(coreEvents.emit).not.toHaveBeenCalledWith(
CoreEvent.RequestEditorSelection,
);
});
it('should update text when openFileInEditor succeeds', async () => {
vi.mocked(openFileInEditor).mockResolvedValue(undefined);
vi.mocked(fs.readFileSync).mockReturnValue('updated text from editor');
const { result } = await renderHook(() =>
useTextBuffer({
initialText: 'initial text',
viewport,
}),
);
await act(async () => {
await result.current.openInExternalEditor();
});
expect(result.current.text).toBe('updated text from editor');
});
it('should log feedback error for other errors', async () => {
const unexpectedError = new Error('Some unexpected error');
vi.mocked(openFileInEditor).mockRejectedValue(unexpectedError);
const { result } = await renderHook(() =>
useTextBuffer({
initialText: 'some text',
viewport,
}),
);
await act(async () => {
await result.current.openInExternalEditor();
});
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'[useTextBuffer] external editor error',
unexpectedError,
);
expect(coreEvents.emit).not.toHaveBeenCalledWith(
CoreEvent.RequestEditorSelection,
);
});
});
@@ -29,10 +29,7 @@ import { Command } from '../../key/keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
import {
openFileInEditor,
EditorNotConfiguredError,
} from '../../utils/editorUtils.js';
import { openFileInEditor } from '../../utils/editorUtils.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5;
@@ -3345,13 +3342,6 @@ export function useTextBuffer({
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
} catch (err) {
if (err instanceof EditorNotConfiguredError) {
coreEvents.emitFeedback(
'warning',
'No external editor configured. Please set your preferred editor in settings.',
);
return;
}
coreEvents.emitFeedback(
'error',
'[useTextBuffer] external editor error',
@@ -29,6 +29,7 @@ describe('useMessageQueue', () => {
streamingState: StreamingState;
submitQuery: (query: string) => void;
isMcpReady: boolean;
isCompressing?: boolean;
}) => {
let hookResult: ReturnType<typeof useMessageQueue>;
function TestComponent(props: typeof initialProps) {
@@ -402,4 +403,52 @@ describe('useMessageQueue', () => {
expect(result.current.messageQueue).toEqual([]);
});
});
describe('isCompressing logic', () => {
it('should not auto-submit when isCompressing is true, even if streamingState is Idle', async () => {
const { result } = await renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
isCompressing: true,
});
// Add messages
act(() => {
result.current.addMessage('Compression message');
});
expect(mockSubmitQuery).not.toHaveBeenCalled();
expect(result.current.messageQueue).toEqual(['Compression message']);
});
it('should auto-submit queued messages when isCompressing becomes false', async () => {
const { result, rerender } = await renderMessageQueueHook({
isConfigInitialized: true,
streamingState: StreamingState.Idle,
submitQuery: mockSubmitQuery,
isMcpReady: true,
isCompressing: true,
});
// Add messages
act(() => {
result.current.addMessage('Pending compression message 1');
result.current.addMessage('Pending compression message 2');
});
expect(mockSubmitQuery).not.toHaveBeenCalled();
// Transition isCompressing to false
rerender({ isCompressing: false });
await waitFor(() => {
expect(mockSubmitQuery).toHaveBeenCalledWith(
'Pending compression message 1\n\nPending compression message 2',
);
expect(result.current.messageQueue).toEqual([]);
});
});
});
});
@@ -12,6 +12,7 @@ export interface UseMessageQueueOptions {
streamingState: StreamingState;
submitQuery: (query: string) => void;
isMcpReady: boolean;
isCompressing?: boolean;
}
export interface UseMessageQueueReturn {
@@ -32,6 +33,7 @@ export function useMessageQueue({
streamingState,
submitQuery,
isMcpReady,
isCompressing = false,
}: UseMessageQueueOptions): UseMessageQueueReturn {
const [messageQueue, setMessageQueue] = useState<string[]>([]);
@@ -69,6 +71,7 @@ export function useMessageQueue({
if (
isConfigInitialized &&
streamingState === StreamingState.Idle &&
!isCompressing &&
isMcpReady &&
messageQueue.length > 0
) {
@@ -84,6 +87,7 @@ export function useMessageQueue({
isMcpReady,
messageQueue,
submitQuery,
isCompressing,
]);
return {
@@ -1,102 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { openFileInEditor, EditorNotConfiguredError } from './editorUtils.js';
import {
spawnSync,
spawn,
type SpawnSyncReturns,
type ChildProcess,
} from 'node:child_process';
import {
CoreEvent,
coreEvents,
getEditorCommand,
} from '@google/gemini-cli-core';
vi.mock('node:child_process', () => ({
spawnSync: vi.fn(),
spawn: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
coreEvents: {
emit: vi.fn(),
emitFeedback: vi.fn(),
},
};
});
describe('editorUtils', () => {
beforeEach(() => {
vi.stubEnv('VISUAL', '');
vi.stubEnv('EDITOR', '');
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should throw EditorNotConfiguredError if no editor is configured', async () => {
await expect(openFileInEditor('test.txt', null, undefined)).rejects.toThrow(
EditorNotConfiguredError,
);
});
it('should use preferredEditorType if provided (terminal editor)', async () => {
vi.mocked(spawnSync).mockReturnValue({
status: 0,
} as SpawnSyncReturns<Buffer>);
await openFileInEditor('test.txt', null, undefined, 'vim');
expect(spawnSync).toHaveBeenCalledWith(
getEditorCommand('vim'),
expect.arrayContaining(['test.txt']),
expect.anything(),
);
expect(coreEvents.emit).toHaveBeenCalledWith(
CoreEvent.ExternalEditorClosed,
);
});
it('should use preferredEditorType if provided (GUI editor)', async () => {
const mockChild = {
on: vi.fn((event: string, cb: (code: number) => void) => {
if (event === 'close') cb(0);
return mockChild;
}),
};
vi.mocked(spawn).mockReturnValue(mockChild as unknown as ChildProcess);
await openFileInEditor('test.txt', null, undefined, 'vscode');
expect(spawn).toHaveBeenCalledWith(
getEditorCommand('vscode'),
expect.arrayContaining(['--wait', 'test.txt']),
expect.anything(),
);
expect(coreEvents.emit).toHaveBeenCalledWith(
CoreEvent.ExternalEditorClosed,
);
});
it('should handle editor exit with non-zero status', async () => {
vi.mocked(spawnSync).mockReturnValue({
status: 1,
} as SpawnSyncReturns<Buffer>);
await expect(
openFileInEditor('test.txt', null, undefined, 'vim'),
).rejects.toThrow('External editor exited with status 1');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
expect.any(String),
expect.any(Error),
);
});
});
+18 -8
View File
@@ -15,13 +15,6 @@ import {
isTerminalEditor,
} from '@google/gemini-cli-core';
export class EditorNotConfiguredError extends Error {
constructor() {
super('No external editor configured');
this.name = 'EditorNotConfiguredError';
}
}
/**
* Opens a file in an external editor and waits for it to close.
* Handles raw mode switching to ensure the editor can interact with the terminal.
@@ -48,7 +41,24 @@ export async function openFileInEditor(
}
if (!command) {
throw new EditorNotConfiguredError();
command = process.env['VISUAL'] ?? process.env['EDITOR'];
if (command) {
const lowerCommand = command.toLowerCase();
const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) =>
lowerCommand.includes(gui),
);
if (
isGui &&
!lowerCommand.includes('--wait') &&
!lowerCommand.includes('-w')
) {
args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait');
}
}
}
if (!command) {
command = process.platform === 'win32' ? 'notepad' : 'vi';
}
const [executable = '', ...initialArgs] = command.split(' ');
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -208,12 +208,20 @@ vi.mock('../config/scoped-config.js', async (importOriginal) => {
...actual,
runWithScopedWorkspaceContext: vi.fn(actual.runWithScopedWorkspaceContext),
createScopedWorkspaceContext: vi.fn(actual.createScopedWorkspaceContext),
runWithScopedAutoMemoryExtractionWriteAccess: vi.fn(
actual.runWithScopedAutoMemoryExtractionWriteAccess,
),
runWithScopedMemoryInboxAccess: vi.fn(
actual.runWithScopedMemoryInboxAccess,
),
};
});
import {
runWithScopedWorkspaceContext,
createScopedWorkspaceContext,
runWithScopedAutoMemoryExtractionWriteAccess,
runWithScopedMemoryInboxAccess,
} from '../config/scoped-config.js';
const mockedRunWithScopedWorkspaceContext = vi.mocked(
runWithScopedWorkspaceContext,
@@ -221,6 +229,12 @@ const mockedRunWithScopedWorkspaceContext = vi.mocked(
const mockedCreateScopedWorkspaceContext = vi.mocked(
createScopedWorkspaceContext,
);
const mockedRunWithScopedMemoryInboxAccess = vi.mocked(
runWithScopedMemoryInboxAccess,
);
const mockedRunWithScopedAutoMemoryExtractionWriteAccess = vi.mocked(
runWithScopedAutoMemoryExtractionWriteAccess,
);
const MockedGeminiChat = vi.mocked(GeminiChat);
const mockedGetDirectoryContextString = vi.mocked(getDirectoryContextString);
@@ -422,6 +436,8 @@ describe('LocalAgentExecutor', () => {
mockedLogAgentFinish.mockReset();
mockedRunWithScopedWorkspaceContext.mockClear();
mockedCreateScopedWorkspaceContext.mockClear();
mockedRunWithScopedMemoryInboxAccess.mockClear();
mockedRunWithScopedAutoMemoryExtractionWriteAccess.mockClear();
mockedPromptIdContext.getStore.mockReset();
mockedPromptIdContext.run.mockImplementation((_id, fn) => fn());
@@ -941,6 +957,52 @@ describe('LocalAgentExecutor', () => {
expect(mockedRunWithScopedWorkspaceContext).toHaveBeenCalledOnce();
});
it('should use runWithScopedMemoryInboxAccess when memoryInboxAccess is set', async () => {
const definition = createTestDefinition();
definition.memoryInboxAccess = true;
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
mockModelResponse([
{
name: COMPLETE_TASK_TOOL_NAME,
args: { finalResult: 'done' },
id: 'c1',
},
]);
await executor.run({ goal: 'test' }, signal);
expect(mockedRunWithScopedMemoryInboxAccess).toHaveBeenCalledOnce();
});
it('should use the extraction write scope when autoMemoryExtractionWriteAccess is set', async () => {
const definition = createTestDefinition();
definition.autoMemoryExtractionWriteAccess = true;
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
mockModelResponse([
{
name: COMPLETE_TASK_TOOL_NAME,
args: { finalResult: 'done' },
id: 'c1',
},
]);
await executor.run({ goal: 'test' }, signal);
expect(
mockedRunWithScopedAutoMemoryExtractionWriteAccess,
).toHaveBeenCalledOnce();
});
it('should not use runWithScopedWorkspaceContext when workspaceDirectories is not set', async () => {
const definition = createTestDefinition();
const executor = await LocalAgentExecutor.create(
@@ -962,6 +1024,10 @@ describe('LocalAgentExecutor', () => {
expect(mockedCreateScopedWorkspaceContext).not.toHaveBeenCalled();
expect(mockedRunWithScopedWorkspaceContext).not.toHaveBeenCalled();
expect(mockedRunWithScopedMemoryInboxAccess).not.toHaveBeenCalled();
expect(
mockedRunWithScopedAutoMemoryExtractionWriteAccess,
).not.toHaveBeenCalled();
});
});
+29 -14
View File
@@ -77,6 +77,8 @@ import {
import type { InjectionSource } from '../config/injectionService.js';
import {
createScopedWorkspaceContext,
runWithScopedAutoMemoryExtractionWriteAccess,
runWithScopedMemoryInboxAccess,
runWithScopedWorkspaceContext,
} from '../config/scoped-config.js';
import { CompleteTaskTool } from '../tools/complete-task.js';
@@ -529,21 +531,34 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
* @returns A promise that resolves to the agent's final output.
*/
async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject> {
// If the agent definition declares additional workspace directories,
// wrap execution in a scoped workspace context. All calls to
// Config.getWorkspaceContext() within this scope will see the extended
// directories, without mutating the shared Config.
const dirs = this.definition.workspaceDirectories;
if (dirs && dirs.length > 0) {
const scopedCtx = createScopedWorkspaceContext(
this.context.config.getWorkspaceContext(),
dirs,
);
return runWithScopedWorkspaceContext(scopedCtx, () =>
this.runInternal(inputs, signal),
);
const runWithWorkspaceScope = () => {
// If the agent definition declares additional workspace directories,
// wrap execution in a scoped workspace context. All calls to
// Config.getWorkspaceContext() within this scope will see the extended
// directories, without mutating the shared Config.
const dirs = this.definition.workspaceDirectories;
if (dirs && dirs.length > 0) {
const scopedCtx = createScopedWorkspaceContext(
this.context.config.getWorkspaceContext(),
dirs,
);
return runWithScopedWorkspaceContext(scopedCtx, () =>
this.runInternal(inputs, signal),
);
}
return this.runInternal(inputs, signal);
};
const runWithInboxScope = () =>
this.definition.memoryInboxAccess
? runWithScopedMemoryInboxAccess(runWithWorkspaceScope)
: runWithWorkspaceScope();
if (this.definition.autoMemoryExtractionWriteAccess) {
return runWithScopedAutoMemoryExtractionWriteAccess(runWithInboxScope);
}
return this.runInternal(inputs, signal);
return runWithInboxScope();
}
private async runInternal(
+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');
}
}
}
@@ -12,6 +12,7 @@ import {
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
@@ -34,6 +35,8 @@ describe('SkillExtractionAgent', () => {
expect(agent.name).toBe('confucius');
expect(agent.displayName).toBe('Skill Extractor');
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(agent.memoryInboxAccess).toBe(true);
expect(agent.autoMemoryExtractionWriteAccess).toBe(true);
expect(agent.toolConfig?.tools).toEqual(
expect.arrayContaining([
READ_FILE_TOOL_NAME,
@@ -44,6 +47,7 @@ describe('SkillExtractionAgent', () => {
GREP_TOOL_NAME,
]),
);
expect(agent.toolConfig?.tools).not.toContain(SHELL_TOOL_NAME);
});
it('should default to no skill unless recurrence and durability are proven', () => {
@@ -69,6 +73,104 @@ describe('SkillExtractionAgent', () => {
expect(prompt).toContain('cannot survive renaming the specific');
});
it('should require all memory updates to go through .inbox/<kind>/*.patch for review', () => {
const prompt = SkillExtractionAgent(
skillsDir,
sessionIndex,
existingSkillsSummary,
'/tmp/memory',
).promptConfig.systemPrompt;
expect(prompt).toContain(
'ALL memory updates are expressed as unified diff `.patch` files',
);
expect(prompt).toContain('EXACTLY ONE canonical patch file per kind');
expect(prompt).toContain('extraction.patch');
expect(prompt).not.toContain('MEMORY.patch');
expect(prompt).not.toContain('verify-workflow.patch');
expect(prompt).toContain('IMPORTANT — incremental updates');
expect(prompt).toContain(
'REWRITE that file by combining its existing hunks with your new',
);
expect(prompt).toContain('private ->');
expect(prompt).toContain('global ->');
expect(prompt).toContain(
'the target MUST be exactly the single global personal memory',
);
expect(prompt).toContain('~/.gemini/GEMINI.md');
expect(prompt).not.toContain('memory.md');
expect(prompt).not.toContain('and siblings');
expect(prompt).toContain(
'Project/workspace shared instructions (GEMINI.md and similar files',
);
expect(prompt).toContain('MEMORY PATCH FORMAT (STRICT)');
expect(prompt).toContain('--- /dev/null');
expect(prompt).toContain('NEVER directly edit MEMORY.md');
expect(prompt).toContain(
'Every patch you write is held for /memory inbox review.',
);
expect(prompt).toContain('the user must approve each patch');
// The MEMORY.md-as-index discipline: sibling creations should pair with
// a MEMORY.md update hunk; the inbox apply step auto-bundles a generic
// pointer if the agent forgets, but the agent should write its own.
expect(prompt).toContain('PRIVATE MEMORY: MEMORY.md IS THE INDEX');
expect(prompt).toContain(
'when you create a new sibling .md file, your patch SHOULD',
);
expect(prompt).toContain('a SECOND HUNK that updates MEMORY.md');
expect(prompt).toContain('inbox apply step');
expect(prompt).toContain('auto-bundle a generic pointer');
// Pointer paths must be ABSOLUTE — the runtime agent reads them directly.
expect(prompt).toContain('IMPORTANT — pointer paths must be ABSOLUTE');
expect(prompt).toContain('Always write the full path');
// The example pointer in the prompt also uses the absolute path.
expect(prompt).toContain(`+- See /tmp/memory/<topic>.md for`);
});
it('surfaces existing inbox patches in the initial query when present', () => {
const pendingInbox = [
'## private (1)',
'',
'### extraction.patch',
'```',
'--- /dev/null',
'+++ /tmp/memory/MEMORY.md',
'@@ -0,0 +1,1 @@',
'+- previously-extracted fact',
'```',
].join('\n');
const agentWithInbox = SkillExtractionAgent(
skillsDir,
sessionIndex,
existingSkillsSummary,
'/tmp/memory',
pendingInbox,
);
const query = agentWithInbox.promptConfig.query ?? '';
expect(query).toContain('# Pending Memory Inbox');
expect(query).toContain('extraction.patch');
expect(query).toContain('previously-extracted fact');
expect(query).toContain(
'REWRITE that patch (overwrite the same path) with',
);
});
it('omits the pending inbox section when nothing is pending', () => {
const agentEmpty = SkillExtractionAgent(
skillsDir,
sessionIndex,
existingSkillsSummary,
'/tmp/memory',
'',
);
const query = agentEmpty.promptConfig.query ?? '';
expect(query).not.toContain('# Pending Memory Inbox');
});
it('should warn that session summaries are user-intent summaries, not workflow evidence', () => {
const query = agent.promptConfig.query ?? '';
@@ -86,7 +188,10 @@ describe('SkillExtractionAgent', () => {
'Only write a skill if the evidence shows a durable, recurring workflow',
);
expect(query).toContain(
'If recurrence or future reuse is unclear, create no skill and explain why.',
'Only write memory if it would clearly help a future session.',
);
expect(query).toContain(
'If recurrence, durability, or future reuse is unclear, create no artifact and explain why.',
);
});
});
@@ -13,7 +13,6 @@ import {
GREP_TOOL_NAME,
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
} from '../tools/tool-names.js';
import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
@@ -21,20 +20,21 @@ import { PREVIEW_GEMINI_FLASH_MODEL } from '../config/models.js';
const SkillExtractionSchema = z.object({
response: z
.string()
.describe('A summary of the skills extracted or updated.'),
.describe('A summary of the memories or skills extracted or updated.'),
});
/**
* Builds the system prompt for the skill extraction agent.
*/
function buildSystemPrompt(skillsDir: string): string {
function buildSystemPrompt(skillsDir: string, memoryDir: string): string {
return [
'You are a Skill Extraction Agent.',
'You are an Auto Memory Extraction Agent.',
'',
'Your job: analyze past conversation sessions and extract reusable skills that will help',
'future agents work more efficiently. You write SKILL.md files to a specific directory.',
'Your job: analyze past conversation sessions and extract durable memory candidates',
'and reusable skills that will help future agents work more efficiently.',
'',
'The goal is to help future agents:',
'- remember durable project facts, preferences, and workflow constraints',
'- solve similar tasks with fewer tool calls and fewer reasoning tokens',
'- reuse proven workflows and verification checklists',
'- avoid known failure modes and landmines',
@@ -48,8 +48,131 @@ function buildSystemPrompt(skillsDir: string): string {
'- Evidence-based only: do not invent facts or claim verification that did not happen.',
'- Redact secrets: never store tokens/keys/passwords; replace with [REDACTED].',
'- Do not copy large tool outputs. Prefer compact summaries + exact error snippets.',
` Write all files under this directory ONLY: ${skillsDir}`,
' NEVER write files outside this directory. You may read session files from the paths provided in the index.',
`- Write all files under this memory work directory ONLY: ${memoryDir}`,
`- Reusable skill candidates go under: ${skillsDir}`,
`- Reviewable memory candidates go under: ${memoryDir}/.inbox`,
' NEVER write files outside the memory work directory. You may read session files from the paths provided in the index.',
'',
'============================================================',
'MEMORY OUTPUTS',
'============================================================',
'',
'ALL memory updates are expressed as unified diff `.patch` files. There is',
`EXACTLY ONE canonical patch file per kind: ${memoryDir}/.inbox/<kind>/extraction.patch`,
'where <kind> is one of:',
'- private -> targets must live under the project memory directory',
` (${memoryDir}). Use this for project-scoped private memory.`,
'- global -> the target MUST be exactly the single global personal memory',
' file ~/.gemini/GEMINI.md. No other files in ~/.gemini/ are',
' writeable; sibling .md files do not exist for the global tier.',
'',
'IMPORTANT — incremental updates:',
'- Before writing a new patch, check if "# Pending Memory Inbox" (above)',
' already lists an `extraction.patch` for the same kind.',
'- If yes: REWRITE that file by combining its existing hunks with your new',
' ones (overwrite the same path with the merged multi-hunk patch). Do NOT',
' create separate `topic-a.patch`, `topic-b.patch` files; everything goes',
' in one canonical `extraction.patch` per kind.',
'- If no: write a new `extraction.patch` with all your hunks.',
'',
'Project/workspace shared instructions (GEMINI.md and similar files under the',
'project root) are NOT auto-extractable. They are managed by humans only; do',
'not write patches that target files under the project root.',
'',
'NEVER directly edit MEMORY.md, GEMINI.md, ~/.gemini/GEMINI.md, settings,',
'credentials, or any file outside the memory work directory. The only way to',
'update memory is via a `.patch` file in the appropriate `.inbox/<kind>/` folder.',
'',
'Every patch you write is held for /memory inbox review. Nothing is applied',
'automatically; the user must approve each patch before it touches active files.',
'',
'Private memory is for durable facts, preferences, decisions, and project context.',
'Skills are only for reusable procedures. If both apply, avoid duplicating the same content.',
'Default to no-op. Prefer 0-5 memory patches and 0-2 skills per run.',
'',
'============================================================',
'PRIVATE MEMORY: MEMORY.md IS THE INDEX (CRITICAL)',
'============================================================',
'',
`In <memoryDir> (${memoryDir}), only MEMORY.md is auto-loaded into future`,
'agent contexts. Sibling .md files (e.g. verify-workflow.md, design-doc.md)',
'are loaded ON DEMAND by the runtime agent via read_file ONLY when MEMORY.md',
'references them.',
'',
'Therefore, when you create a new sibling .md file, your patch SHOULD',
'include a SECOND HUNK that updates MEMORY.md to add a one-line pointer',
'to the new file. The pointer is what makes the sibling discoverable to',
'future agents.',
'',
'IMPORTANT — pointer paths must be ABSOLUTE. Future agents `read_file`',
`directly off the pointer line, so the path must resolve without knowing`,
`<memoryDir>. Always write the full path (${memoryDir}/<topic>.md), never`,
'just the basename. The auto-bundle fallback also writes absolute paths.',
'',
'If you forget to include the MEMORY.md pointer, the inbox apply step',
`will auto-bundle a generic pointer (\`- See ${memoryDir}/<name>.md for ...\`)`,
'so the sibling is at least discoverable. But that auto-pointer is dumb —',
'write the proper paired hunk yourself so MEMORY.md gets a meaningful',
'summary.',
'',
'Correct shape for "create a new sibling" patch:',
'',
' --- /dev/null',
` +++ ${memoryDir}/<topic>.md`,
' @@ -0,0 +1,N @@',
' +# <topic>',
' +...',
'',
` --- ${memoryDir}/MEMORY.md`,
` +++ ${memoryDir}/MEMORY.md`,
' @@ -<line>,3 +<line>,4 @@',
' <context>',
' <context>',
' <context>',
` +- See ${memoryDir}/<topic>.md for <one-line summary>.`,
'',
'For brief facts (a few lines), prefer adding the entry directly to MEMORY.md',
'as a single-hunk patch — no sibling file needed. Only spawn a sibling file',
'when the content has substantial detail (multiple sections, procedures, etc.).',
'',
'============================================================',
'MEMORY PATCH FORMAT (STRICT)',
'============================================================',
'',
'Always read the target file first with read_file (or skip the read if the file',
'definitely does not exist yet) so the patch context lines match exactly.',
'',
'Use one of these two unified diff shapes inside each `.patch` file:',
'',
'1. Update an existing file:',
'',
' --- /absolute/path/to/target.md',
' +++ /absolute/path/to/target.md',
' @@ -<oldStart>,<oldCount> +<newStart>,<newCount> @@',
' <unchanged context line>',
' -<removed line>',
' +<added line>',
' <unchanged context line>',
'',
'2. Create a brand-new file (no existing target):',
'',
' --- /dev/null',
' +++ /absolute/path/to/new-target.md',
' @@ -0,0 +1,<count> @@',
' +<line 1>',
' +<line 2>',
'',
'Patch rules:',
'- Use the EXACT absolute file path in BOTH --- and +++ headers (NO `a/`/`b/` prefixes).',
'- For updates, both headers must be the SAME absolute path.',
'- Include 3 lines of context around each change for updates.',
'- Line counts in @@ headers MUST be accurate.',
'- One `.patch` file may include multiple hunks across multiple files in the same kind.',
'- The patch FILENAME under .inbox/<kind>/ MUST be the canonical',
' `extraction.patch`; the headers determine the actual target file(s).',
'- Patches that fail validation or fail to apply cleanly are discarded silently.',
"- The header path must resolve under the kind's allowed root (see above) or the",
' patch will be rejected.',
'',
'============================================================',
'NO-OP / MINIMUM SIGNAL GATE',
@@ -212,8 +335,7 @@ function buildSystemPrompt(skillsDir: string): string {
'2. If skills exist, read their SKILL.md files to understand what is already captured.',
'3. Use activate_skill to load the "skill-creator" skill. Follow its design guidance',
' (conciseness, progressive disclosure, frontmatter format, bundled resources) when',
' writing SKILL.md files. You may also use its init_skill.cjs script to scaffold new',
' skill directories and package_skill.cjs to validate finished skills.',
' writing SKILL.md files.',
' IMPORTANT: You are a background agent with no user interaction. Skip any interactive',
' steps in the skill-creator guide (asking clarifying questions, requesting user feedback,',
' installation prompts, iteration loops). Use only its format and quality guidance.',
@@ -228,15 +350,19 @@ function buildSystemPrompt(skillsDir: string): string {
'7. For each candidate, verify it meets ALL criteria. Before writing, make sure you can',
' state: future trigger, evidence sessions, recurrence signal, validation signal, and',
' why it is not generic.',
'8. Write new SKILL.md files or update existing ones in your directory.',
' Use run_shell_command to run init_skill.cjs for scaffolding and package_skill.cjs for validation.',
' For skills that live OUTSIDE your directory, write a .patch file instead (see UPDATING EXISTING SKILLS).',
'9. Write COMPLETE files — never partially update a SKILL.md.',
'8. For memory candidates: read the target file first (or confirm it does not exist),',
' then write a `.patch` file under the appropriate .inbox/<kind>/ directory using',
' the format in MEMORY PATCH FORMAT. Prefer updating existing memory files over',
' duplicating facts. Keep patches small and focused.',
'9. Write new SKILL.md files or update existing ones in your skills directory.',
' Use write_file/edit directly; shell commands are intentionally unavailable in this background flow.',
' For skills that live OUTSIDE your skills directory, write a `.patch` file there instead (see UPDATING EXISTING SKILLS).',
'10. Write COMPLETE SKILL.md files — never partially update a SKILL.md.',
'',
'IMPORTANT: Do NOT read every session. Only read sessions whose summaries suggest a',
'repeated pattern or a stable recurring repo workflow worth investigating. Most runs',
'should read 0-3 sessions and create 0 skills.',
'Do not explore the codebase. Work only with the session index, session files, and the skills directory.',
'should read 0-3 sessions and create few or no artifacts.',
'Do not explore the codebase. Work only with the session index, session files, and the memory work directory.',
].join('\n');
}
@@ -253,12 +379,20 @@ export const SkillExtractionAgent = (
skillsDir: string,
sessionIndex: string,
existingSkillsSummary: string,
memoryDir: string = skillsDir.replace(/[/\\]skills$/, ''),
/**
* Snapshot of the current memory inbox state, formatted for the agent's
* initial context. Lets the agent see what's already pending so it can
* extend or rewrite existing canonical patches instead of accumulating
* many small ones across sessions. Empty string = nothing pending.
*/
pendingInboxSummary: string = '',
): LocalAgentDefinition<typeof SkillExtractionSchema> => ({
kind: 'local',
name: 'confucius',
displayName: 'Skill Extractor',
description:
'Extracts reusable skills from past conversation sessions and writes them as SKILL.md files.',
'Extracts durable memories and reusable skills from past conversation sessions.',
inputConfig: {
inputSchema: {
type: 'object',
@@ -279,6 +413,8 @@ export const SkillExtractionAgent = (
modelConfig: {
model: PREVIEW_GEMINI_FLASH_MODEL,
},
memoryInboxAccess: true,
autoMemoryExtractionWriteAccess: true,
toolConfig: {
tools: [
ACTIVATE_SKILL_TOOL_NAME,
@@ -288,7 +424,6 @@ export const SkillExtractionAgent = (
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
SHELL_TOOL_NAME,
],
},
get promptConfig() {
@@ -298,6 +433,23 @@ export const SkillExtractionAgent = (
contextParts.push(`# Existing Skills\n\n${existingSkillsSummary}`);
}
if (pendingInboxSummary && pendingInboxSummary.trim().length > 0) {
contextParts.push(
[
'# Pending Memory Inbox',
'',
'The following `.patch` files already exist in the memory inbox',
'awaiting user review. If your new findings overlap with one of',
'these patches, REWRITE that patch (overwrite the same path) with',
'the merged content rather than creating a new patch file. Use the',
'canonical filename `extraction.patch` per kind for any new patch',
'so the inbox stays consolidated.',
'',
pendingInboxSummary,
].join('\n'),
);
}
contextParts.push(
[
'# Session Index',
@@ -326,8 +478,8 @@ export const SkillExtractionAgent = (
.replace(/\$\{(\w+)\}/g, '{$1}');
return {
systemPrompt: buildSystemPrompt(skillsDir),
query: `${initialContext}\n\nAnalyze the session index above. Session summaries describe user intent; optional workflow hints describe likely procedural traces. Use workflow hints for routing, then read sessions that suggest repeated workflows using read_file to verify recurrence from transcript evidence. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. If recurrence or future reuse is unclear, create no skill and explain why.`,
systemPrompt: buildSystemPrompt(skillsDir, memoryDir),
query: `${initialContext}\n\nAnalyze the session index above. Session summaries describe user intent; optional workflow hints describe likely procedural traces. Use workflow hints for routing, then read sessions that suggest durable memory or repeated workflows using read_file to verify from transcript evidence. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. Only write memory if it would clearly help a future session. If recurrence, durability, or future reuse is unclear, create no artifact and explain why. If no skill is justified, create no skill and explain why.`,
};
},
runConfig: {
+28
View File
@@ -229,6 +229,21 @@ export interface LocalAgentDefinition<
*/
workspaceDirectories?: string[];
/**
* Allows this agent to access the canonical auto-memory inbox patch files
* under `<projectMemoryDir>/.inbox/{private,global}/extraction.patch`.
* This is intentionally narrow so the main session cannot bypass review by
* writing arbitrary inbox patches.
*/
memoryInboxAccess?: boolean;
/**
* Restricts write validation for this agent to extracted skill artifacts and
* canonical auto-memory inbox patch files. Used by the background
* auto-memory extractor so active memory files cannot be edited directly.
*/
autoMemoryExtractionWriteAccess?: boolean;
/**
* Optional inline MCP servers for this agent.
*/
@@ -354,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[];
}
+617
View File
@@ -12,9 +12,12 @@ import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import {
addMemory,
applyInboxMemoryPatch,
dismissInboxSkill,
dismissInboxMemoryPatch,
listInboxSkills,
listInboxPatches,
listInboxMemoryPatches,
applyInboxPatch,
dismissInboxPatch,
listMemoryFiles,
@@ -31,6 +34,7 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
vi.mock('../config/storage.js', () => ({
Storage: {
getUserSkillsDir: vi.fn(),
getGlobalGeminiDir: vi.fn(),
},
}));
@@ -315,6 +319,619 @@ describe('memory commands', () => {
});
});
describe('memory patch inbox', () => {
let tmpDir: string;
let memoryTempDir: string;
let projectRoot: string;
let globalMemoryDir: string;
let patchConfig: Config;
function buildUpdatePatch(
absoluteTargetPath: string,
original: string,
updated: string,
): string {
// Minimal one-hunk patch that replaces `original` with `updated`.
const oldLines = original === '' ? 0 : original.split('\n').length - 1;
const newLines = updated === '' ? 0 : updated.split('\n').length - 1;
const removed = original
.split('\n')
.slice(0, oldLines)
.map((line) => `-${line}`);
const added = updated
.split('\n')
.slice(0, newLines)
.map((line) => `+${line}`);
return [
`--- ${absoluteTargetPath}`,
`+++ ${absoluteTargetPath}`,
`@@ -1,${oldLines} +1,${newLines} @@`,
...removed,
...added,
'',
].join('\n');
}
function buildCreationPatch(
absoluteTargetPath: string,
content: string,
): string {
const contentLines = content.split('\n');
const lineCount = content.endsWith('\n')
? contentLines.length - 1
: contentLines.length;
const additions = (
content.endsWith('\n') ? contentLines.slice(0, -1) : contentLines
).map((line) => `+${line}`);
return [
`--- /dev/null`,
`+++ ${absoluteTargetPath}`,
`@@ -0,0 +1,${lineCount} @@`,
...additions,
'',
].join('\n');
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memory-patch-test-'));
// Canonicalize so test-side paths match production's
// canonicalizeDirIfPresent → fs.realpath. On Windows runners
// os.tmpdir() returns the 8.3 short form (C:\Users\RUNNER~1\...) but
// fs.realpath expands it to the long form (C:\Users\runneradmin\...),
// which would otherwise break the auto-pointer absolute-path asserts.
tmpDir = await fs.realpath(tmpDir);
memoryTempDir = path.join(tmpDir, 'memory-temp');
projectRoot = path.join(tmpDir, 'project');
globalMemoryDir = path.join(tmpDir, 'global');
await fs.mkdir(memoryTempDir, { recursive: true });
await fs.mkdir(projectRoot, { recursive: true });
await fs.mkdir(globalMemoryDir, { recursive: true });
patchConfig = {
storage: {
getProjectMemoryTempDir: () => memoryTempDir,
getProjectMemoryDir: () => memoryTempDir,
},
isTrustedFolder: () => true,
} as unknown as Config;
vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(globalMemoryDir);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('aggregates all .patch files of a kind into a single inbox entry', async () => {
// Multiple physical .patch files in the kind dir → ONE consolidated
// inbox entry per kind, with all hunks merged into entries[].
const target = path.join(memoryTempDir, 'MEMORY.md');
await fs.writeFile(target, '- old\n');
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'a-update.patch'),
buildUpdatePatch(target, '- old\n', '- new\n'),
);
// Second source patch — same kind, different hunk.
const sibling = path.join(memoryTempDir, 'topic.md');
await fs.writeFile(sibling, 'topic A\n');
await fs.writeFile(
path.join(patchDir, 'b-topic.patch'),
buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
);
const patches = await listInboxMemoryPatches(patchConfig);
expect(patches).toHaveLength(1);
const memoryPatch = patches[0];
expect(memoryPatch).toMatchObject({
kind: 'private',
relativePath: 'private',
name: 'Private memory',
});
// Both source files contributed their hunks.
expect(memoryPatch.entries).toHaveLength(2);
expect(memoryPatch.sourceFiles).toEqual([
'a-update.patch',
'b-topic.patch',
]);
expect(memoryPatch.entries[0].targetPath).toBe(target);
expect(memoryPatch.entries[0].isNewFile).toBe(false);
expect(memoryPatch.entries[1].targetPath).toBe(sibling);
expect(memoryPatch.extractedAt).toBeDefined();
});
it('omits patches whose headers leave the allowed root from the listing', async () => {
// Bad patches must NOT show up in the inbox at all — listing filters
// them out so the user only ever sees actionable items. (They'd also
// be rejected at Apply time, but we don't want to surface them.)
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'escape.patch'),
buildCreationPatch(path.join(projectRoot, 'GEMINI.md'), 'Hi.\n'),
);
const patches = await listInboxMemoryPatches(patchConfig);
expect(patches).toHaveLength(0);
// Direct apply still rejects it (defense-in-depth).
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'escape.patch',
);
expect(result.success).toBe(false);
expect(result.message).toMatch(/outside the private memory root/i);
});
it('omits global patches with disallowed targets from the listing', async () => {
// Same defense for the global tier: only ~/.gemini/GEMINI.md is allowed.
// memory.md (legacy lowercase), sibling .md files, and settings.json all
// get filtered out of the listing instead of confusing the user.
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'wrong-name.patch'),
buildCreationPatch(
path.join(globalMemoryDir, 'memory.md'),
'rejected\n',
),
);
await fs.writeFile(
path.join(patchDir, 'sibling.patch'),
buildCreationPatch(
path.join(globalMemoryDir, 'notes.md'),
'rejected\n',
),
);
await fs.writeFile(
path.join(patchDir, 'settings.patch'),
buildCreationPatch(path.join(globalMemoryDir, 'settings.json'), '{}\n'),
);
const patches = await listInboxMemoryPatches(patchConfig);
expect(patches).toHaveLength(0);
});
it('applies a private update patch and removes it from the inbox', async () => {
const target = path.join(memoryTempDir, 'MEMORY.md');
await fs.writeFile(target, '- old\n');
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'MEMORY.patch'),
buildUpdatePatch(target, '- old\n', '- accepted\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'MEMORY.patch',
);
expect(result.success).toBe(true);
await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- accepted\n');
await expect(
fs.access(path.join(patchDir, 'MEMORY.patch')),
).rejects.toThrow();
});
it('applies a private creation patch with a paired MEMORY.md pointer', async () => {
// The auto-memory contract: creating a sibling .md file requires a
// hunk that adds a pointer to MEMORY.md (so the sibling becomes
// discoverable to future sessions).
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
await fs.writeFile(memoryMd, '# Project Memory\n');
const target = path.join(memoryTempDir, 'topic.md');
await expect(fs.access(target)).rejects.toThrow();
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
const multiHunkPatch =
buildCreationPatch(target, '# Topic\n- new fact\n') +
buildUpdatePatch(
memoryMd,
'# Project Memory\n',
'# Project Memory\n- See topic.md for the new fact.\n',
);
await fs.writeFile(path.join(patchDir, 'topic.patch'), multiHunkPatch);
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'topic.patch',
);
expect(result.success).toBe(true);
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
'# Topic\n- new fact\n',
);
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toContain(
'See topic.md',
);
await expect(
fs.access(path.join(patchDir, 'topic.patch')),
).rejects.toThrow();
});
it('auto-bundles a MEMORY.md pointer when the patch creates an orphan sibling', async () => {
// Sibling .md files in <memoryDir> are loaded by future sessions ONLY
// when MEMORY.md references them. To avoid orphans, applying a sibling
// creation patch with no MEMORY.md update auto-bundles a pointer line.
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
await fs.writeFile(memoryMd, '# Project Memory\n');
const target = path.join(memoryTempDir, 'orphan-topic.md');
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'orphan-topic.patch'),
buildCreationPatch(target, '# Orphan Topic\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'orphan-topic.patch',
);
expect(result.success).toBe(true);
expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
expect(result.message).toContain('"orphan-topic.md"');
// The sibling exists.
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
'# Orphan Topic\n',
);
// MEMORY.md now references the sibling — using ABSOLUTE PATH so a
// future agent can `read_file` it without resolving relatives. We
// assert the line shape is `- See <absolute>/orphan-topic.md ...` and
// verify the path is absolute via path.isAbsolute (cross-platform —
// the previous /^- See \/.+\/.../ regex was Unix-only and broke on
// Windows where the absolute path is e.g. `C:\Users\...\orphan-topic.md`).
const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
expect(memoryAfter).toContain(target);
const pointerLineMatch = memoryAfter.match(
/^- See (.+orphan-topic\.md) /m,
);
expect(pointerLineMatch).not.toBeNull();
expect(path.isAbsolute(pointerLineMatch![1])).toBe(true);
// The patch was committed and removed from inbox.
await expect(
fs.access(path.join(patchDir, 'orphan-topic.patch')),
).rejects.toThrow();
});
it('auto-creates MEMORY.md if it does not exist when bundling pointers', async () => {
// No MEMORY.md on disk + a creation patch for a sibling →
// auto-bundle should create MEMORY.md from scratch with the pointer.
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
await expect(fs.access(memoryMd)).rejects.toThrow();
const target = path.join(memoryTempDir, 'fresh-topic.md');
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'fresh-topic.patch'),
buildCreationPatch(target, '# Fresh Topic\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'fresh-topic.patch',
);
expect(result.success).toBe(true);
expect(result.message).toMatch(/auto-added MEMORY\.md pointer/i);
const memoryAfter = await fs.readFile(memoryMd, 'utf-8');
expect(memoryAfter).toContain('Project Memory');
// Pointer must be absolute so the future agent can read_file directly.
expect(memoryAfter).toContain(target);
});
it('accepts a private creation patch when MEMORY.md already references the new file', async () => {
// If MEMORY.md was previously prepared with a pointer (e.g. by a
// separately-applied patch), the follow-up creation patch is fine.
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
await fs.writeFile(
memoryMd,
'# Project Memory\n- See later-topic.md for details.\n',
);
const target = path.join(memoryTempDir, 'later-topic.md');
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'later-topic.patch'),
buildCreationPatch(target, '# Later Topic\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'later-topic.patch',
);
expect(result.success).toBe(true);
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
'# Later Topic\n',
);
});
it('applies a global creation patch to ~/.gemini/GEMINI.md', async () => {
const target = path.join(globalMemoryDir, 'GEMINI.md');
// Sanity check: target does not exist before apply.
await expect(fs.access(target)).rejects.toThrow();
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'GEMINI.patch'),
buildCreationPatch(target, '# Personal preferences\n- prefer X\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'global',
'GEMINI.patch',
);
expect(result.success).toBe(true);
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
'# Personal preferences\n- prefer X\n',
);
await expect(
fs.access(path.join(patchDir, 'GEMINI.patch')),
).rejects.toThrow();
});
it('applies a global update patch to ~/.gemini/GEMINI.md', async () => {
const target = path.join(globalMemoryDir, 'GEMINI.md');
await fs.writeFile(target, '- prefer X\n');
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'GEMINI.patch'),
buildUpdatePatch(target, '- prefer X\n', '- prefer Y\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'global',
'GEMINI.patch',
);
expect(result.success).toBe(true);
await expect(fs.readFile(target, 'utf-8')).resolves.toBe('- prefer Y\n');
await expect(
fs.access(path.join(patchDir, 'GEMINI.patch')),
).rejects.toThrow();
});
it('dismisses a single memory patch from the inbox (legacy single-file mode)', async () => {
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'GEMINI.patch'),
buildCreationPatch(
path.join(globalMemoryDir, 'GEMINI.md'),
'Prefer concise.\n',
),
);
const result = await dismissInboxMemoryPatch(
patchConfig,
'global',
'GEMINI.patch',
);
expect(result.success).toBe(true);
await expect(
fs.access(path.join(patchDir, 'GEMINI.patch')),
).rejects.toThrow();
});
it('apply with relativePath = kind runs every source patch in sequence', async () => {
// Aggregate apply: pass `relativePath = kind`. Each .patch file under
// the kind dir is applied atomically in lexical order; the result
// message summarizes successes/failures.
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
await fs.writeFile(memoryMd, '- old\n');
const sibling = path.join(memoryTempDir, 'topic.md');
await fs.writeFile(sibling, 'topic A\n');
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'a-update.patch'),
buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
);
await fs.writeFile(
path.join(patchDir, 'b-topic.patch'),
buildUpdatePatch(sibling, 'topic A\n', 'topic B\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'private', // ← aggregate mode
);
expect(result.success).toBe(true);
expect(result.message).toMatch(/applied all 2 private memory patches/i);
// Both targets were updated, both source patches removed.
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
await expect(fs.readFile(sibling, 'utf-8')).resolves.toBe('topic B\n');
await expect(
fs.access(path.join(patchDir, 'a-update.patch')),
).rejects.toThrow();
await expect(
fs.access(path.join(patchDir, 'b-topic.patch')),
).rejects.toThrow();
});
it('aggregate apply reports successes and failures when one source patch is stale', async () => {
const memoryMd = path.join(memoryTempDir, 'MEMORY.md');
await fs.writeFile(memoryMd, '- old\n');
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
// Good patch: updates the existing line.
await fs.writeFile(
path.join(patchDir, 'a-good.patch'),
buildUpdatePatch(memoryMd, '- old\n', '- new\n'),
);
// Stale patch: context expects something that doesn't exist.
await fs.writeFile(
path.join(patchDir, 'b-stale.patch'),
buildUpdatePatch(memoryMd, '- never existed\n', '- attempted\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'private',
);
// Any failure → success=false so the dialog keeps the inbox entry
// visible. (The successful sub-patches were already removed from disk;
// the next listing will surface only the failures for retry.)
expect(result.success).toBe(false);
expect(result.message).toMatch(/applied 1 of 2/i);
expect(result.message).toMatch(/b-stale\.patch/);
// Good patch committed and removed; stale patch stays in inbox.
await expect(fs.readFile(memoryMd, 'utf-8')).resolves.toBe('- new\n');
await expect(
fs.access(path.join(patchDir, 'a-good.patch')),
).rejects.toThrow();
await expect(
fs.access(path.join(patchDir, 'b-stale.patch')),
).resolves.toBeUndefined();
});
it('dismiss with relativePath = kind removes all source patches', async () => {
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'a.patch'),
buildCreationPatch(path.join(memoryTempDir, 'a.md'), 'a\n'),
);
await fs.writeFile(
path.join(patchDir, 'b.patch'),
buildCreationPatch(path.join(memoryTempDir, 'b.md'), 'b\n'),
);
const result = await dismissInboxMemoryPatch(
patchConfig,
'private',
'private',
);
expect(result.success).toBe(true);
expect(result.message).toMatch(/dismissed 2/i);
await expect(fs.access(path.join(patchDir, 'a.patch'))).rejects.toThrow();
await expect(fs.access(path.join(patchDir, 'b.patch'))).rejects.toThrow();
});
it('rejects global patches that target anything other than ~/.gemini/GEMINI.md', async () => {
const patchDir = path.join(memoryTempDir, '.inbox', 'global');
await fs.mkdir(patchDir, { recursive: true });
// memory.md (lowercase) is NOT a valid global memory file.
await fs.writeFile(
path.join(patchDir, 'wrong-name.patch'),
buildCreationPatch(
path.join(globalMemoryDir, 'memory.md'),
'Should be rejected.\n',
),
);
// Sibling .md files in ~/.gemini/ are also not allowed.
await fs.writeFile(
path.join(patchDir, 'sibling.patch'),
buildCreationPatch(
path.join(globalMemoryDir, 'notes.md'),
'Should be rejected.\n',
),
);
// Non-memory files (settings, credentials) must stay off-limits.
await fs.writeFile(
path.join(patchDir, 'settings.patch'),
buildCreationPatch(
path.join(globalMemoryDir, 'settings.json'),
'{"foo": 1}\n',
),
);
for (const fileName of [
'wrong-name.patch',
'sibling.patch',
'settings.patch',
]) {
const result = await applyInboxMemoryPatch(
patchConfig,
'global',
fileName,
);
expect(result.success).toBe(false);
expect(result.message).toMatch(/outside the global memory root/i);
}
// None of the bogus targets were created.
for (const orphan of ['memory.md', 'notes.md', 'settings.json']) {
await expect(
fs.access(path.join(globalMemoryDir, orphan)),
).rejects.toThrow();
}
});
it('rejects invalid memory patch paths', async () => {
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'../MEMORY.patch',
);
expect(result.success).toBe(false);
expect(result.message).toBe('Invalid memory patch path.');
});
it('rejects a creation patch whose target already exists', async () => {
const target = path.join(memoryTempDir, 'MEMORY.md');
await fs.writeFile(target, 'pre-existing\n');
const patchDir = path.join(memoryTempDir, '.inbox', 'private');
await fs.mkdir(patchDir, { recursive: true });
await fs.writeFile(
path.join(patchDir, 'MEMORY.patch'),
buildCreationPatch(target, 'replacement\n'),
);
const result = await applyInboxMemoryPatch(
patchConfig,
'private',
'MEMORY.patch',
);
expect(result.success).toBe(false);
expect(result.message).toMatch(/declares a new file/);
await expect(fs.readFile(target, 'utf-8')).resolves.toBe(
'pre-existing\n',
);
await expect(
fs.access(path.join(patchDir, 'MEMORY.patch')),
).resolves.toBeUndefined();
});
});
describe('moveInboxSkill', () => {
let tmpDir: string;
let skillsDir: string;
+799
View File
@@ -13,11 +13,15 @@ import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { flattenMemory } from '../config/memory.js';
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
import { getGlobalMemoryFilePath } from '../tools/memoryTool.js';
import {
type AppliedSkillPatchTarget,
applyParsedPatchesWithAllowedRoots,
applyParsedSkillPatches,
canonicalizeAllowedPatchRoots,
hasParsedPatchHunks,
isProjectSkillPatchTarget,
resolveTargetWithinAllowedRoots,
validateParsedSkillPatchHeaders,
} from '../services/memoryPatchUtils.js';
import { readExtractionState } from '../services/memoryService.js';
@@ -338,6 +342,46 @@ export interface InboxPatch {
extractedAt?: string;
}
export type InboxMemoryPatchKind = 'private' | 'global';
/**
* One target file inside a memory patch (most patches will have a single entry).
*/
export interface InboxMemoryPatchEntry {
/** Absolute path of the markdown file the patch will modify. */
targetPath: string;
/** Unified diff for this single file (used for UI preview). */
diffContent: string;
/** True when this entry creates a new file (`/dev/null` source). */
isNewFile: boolean;
}
/**
* Represents the AGGREGATED inbox state for one memory kind. Even when the
* extraction agent has produced multiple `.patch` files under
* `<memoryDir>/.inbox/<kind>/` (e.g. across several sessions), the inbox
* surfaces them as ONE entry per kind. Apply runs each underlying patch in
* sequence; Dismiss removes them all.
*/
export interface InboxMemoryPatch {
/** Memory tier — one entry per kind in the inbox. */
kind: InboxMemoryPatchKind;
/**
* Stable identifier for this consolidated entry. Set to the kind itself
* (`"private"` or `"global"`); kept in the type for backwards-compat with
* the per-file API the dialog passes through.
*/
relativePath: string;
/** Display name shown in the inbox row (e.g. `"Private memory"`). */
name: string;
/** All hunks from all underlying source patches, concatenated in order. */
entries: InboxMemoryPatchEntry[];
/** Basenames of the underlying `.patch` files being aggregated. */
sourceFiles: string[];
/** Most recent mtime across the source files (ISO string), if known. */
extractedAt?: string;
}
interface StagedInboxPatchTarget {
targetPath: string;
tempPath: string;
@@ -372,6 +416,97 @@ function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getMemoryPatchRoot(
memoryDir: string,
kind: InboxMemoryPatchKind,
): string {
return path.join(memoryDir, '.inbox', kind);
}
function isSubpathOrSame(childPath: string, parentPath: string): boolean {
const relativePath = path.relative(parentPath, childPath);
return (
relativePath === '' ||
(!relativePath.startsWith('..') && !path.isAbsolute(relativePath))
);
}
function normalizeInboxMemoryPatchPath(
relativePath: string,
): string | undefined {
if (
relativePath.length === 0 ||
path.isAbsolute(relativePath) ||
relativePath.includes('\\')
) {
return undefined;
}
const normalizedPath = path.posix.normalize(relativePath);
if (
normalizedPath === '.' ||
normalizedPath.startsWith('../') ||
normalizedPath === '..' ||
!normalizedPath.endsWith('.patch')
) {
return undefined;
}
return normalizedPath;
}
/**
* Returns the directory roots (or single-file allowlists) that a memory patch
* of the given kind is allowed to modify. Memory patch headers must reference
* paths inside / equal to one of these entries after canonical resolution.
*
* - `private` allows any markdown file inside the project memory directory.
* - `global` is intentionally a single-file allowlist: the only writeable
* global file is the personal `~/.gemini/GEMINI.md`. Other files under
* `~/.gemini/` (settings, credentials, oauth, keybindings, etc.) are off-limits.
*/
export function getAllowedMemoryPatchRoots(
config: Config,
kind: InboxMemoryPatchKind,
): string[] {
switch (kind) {
case 'private':
return [path.resolve(config.storage.getProjectMemoryTempDir())];
case 'global':
return [path.resolve(getGlobalMemoryFilePath())];
default:
throw new Error(`Unknown memory patch kind: ${kind as string}`);
}
}
async function getFileMtimeIso(filePath: string): Promise<string | undefined> {
try {
const stats = await fs.stat(filePath);
return stats.mtime.toISOString();
} catch {
return undefined;
}
}
async function getInboxMemoryPatchSourcePath(
config: Config,
kind: InboxMemoryPatchKind,
relativePath: string,
): Promise<string | undefined> {
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
if (!normalizedPath) {
return undefined;
}
const patchRoot = path.resolve(
getMemoryPatchRoot(config.storage.getProjectMemoryTempDir(), kind),
);
const sourcePath = path.resolve(patchRoot, ...normalizedPath.split('/'));
if (!isSubpathOrSame(sourcePath, patchRoot)) {
return undefined;
}
return sourcePath;
}
async function patchTargetsProjectSkills(
targetPaths: string[],
config: Config,
@@ -395,6 +530,670 @@ async function getPatchExtractedAt(
}
}
function formatMemoryKindLabel(kind: InboxMemoryPatchKind): string {
switch (kind) {
case 'private':
return 'Private memory';
case 'global':
return 'Global memory';
default:
return kind;
}
}
/**
* Returns the absolute paths of every `.patch` file currently in the kind's
* inbox directory (sorted by basename for stable ordering at apply time).
*
* NOTE: this is a raw filesystem listing it does NOT validate patch shape
* or that targets fall inside the kind's allowed root. Callers that need
* "what the user actually sees in the inbox" should use `listValidInboxPatchFiles`.
*/
async function listInboxPatchFiles(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<string[]> {
const patchRoot = getMemoryPatchRoot(
config.storage.getProjectMemoryTempDir(),
kind,
);
const found: string[] = [];
async function walk(currentDir: string): Promise<void> {
let dirEntries: Array<import('node:fs').Dirent>;
try {
dirEntries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of dirEntries) {
const entryPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
continue;
}
if (entry.isFile() && entry.name.endsWith('.patch')) {
found.push(entryPath);
}
}
}
await walk(patchRoot);
return found.sort();
}
/**
* Returns only the inbox patch files that pass the same validation as the
* inbox listing (parseable, has hunks, valid headers, targets in the
* kind's allowed root). Used by aggregate apply so the user only ever sees
* results for patches the inbox actually surfaced.
*/
async function listValidInboxPatchFiles(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<string[]> {
const patchFiles = await listInboxPatchFiles(config, kind);
if (patchFiles.length === 0) {
return [];
}
const allowedRoots = await canonicalizeAllowedPatchRoots(
getAllowedMemoryPatchRoots(config, kind),
);
const valid: string[] = [];
for (const sourcePath of patchFiles) {
let content: string;
try {
content = await fs.readFile(sourcePath, 'utf-8');
} catch {
continue;
}
let parsed: Diff.StructuredPatch[];
try {
parsed = Diff.parsePatch(content);
} catch {
continue;
}
if (!hasParsedPatchHunks(parsed)) {
continue;
}
const validated = validateParsedSkillPatchHeaders(parsed);
if (!validated.success) {
continue;
}
const targetsAllAllowed = await Promise.all(
validated.patches.map(
async (header) =>
(await resolveTargetWithinAllowedRoots(
header.targetPath,
allowedRoots,
)) !== undefined,
),
);
if (!targetsAllAllowed.every(Boolean)) {
continue;
}
valid.push(sourcePath);
}
return valid;
}
/**
* Scans `<memoryDir>/.inbox/{private,global}/` and returns ONE consolidated
* inbox entry per kind. Each entry aggregates all hunks from every valid
* underlying `.patch` file. Patches that fail validation (unparseable, no
* hunks, target outside allowed root) are silently skipped so they don't
* pollute the inbox UI.
*/
export async function listInboxMemoryPatches(
config: Config,
): Promise<InboxMemoryPatch[]> {
const kinds: InboxMemoryPatchKind[] = ['private', 'global'];
const aggregated: InboxMemoryPatch[] = [];
for (const kind of kinds) {
const allowedRoots = await canonicalizeAllowedPatchRoots(
getAllowedMemoryPatchRoots(config, kind),
);
const patchFiles = await listInboxPatchFiles(config, kind);
const aggregatedEntries: InboxMemoryPatchEntry[] = [];
const sourceFiles: string[] = [];
let latestMtime: string | undefined;
for (const sourcePath of patchFiles) {
let content: string;
try {
content = await fs.readFile(sourcePath, 'utf-8');
} catch {
continue;
}
let parsed: Diff.StructuredPatch[];
try {
parsed = Diff.parsePatch(content);
} catch {
continue;
}
if (!hasParsedPatchHunks(parsed)) {
continue;
}
const validated = validateParsedSkillPatchHeaders(parsed);
if (!validated.success) {
continue;
}
// Skip the entire source file if ANY of its targets escapes the kind's
// allowed root.
const targetsAllAllowed = await Promise.all(
validated.patches.map(
async (header) =>
(await resolveTargetWithinAllowedRoots(
header.targetPath,
allowedRoots,
)) !== undefined,
),
);
if (!targetsAllAllowed.every(Boolean)) {
continue;
}
for (const [index, header] of validated.patches.entries()) {
aggregatedEntries.push({
targetPath: header.targetPath,
isNewFile: header.isNewFile,
diffContent: formatParsedDiff(parsed[index]),
});
}
sourceFiles.push(path.basename(sourcePath));
const mtime = await getFileMtimeIso(sourcePath);
if (mtime && (!latestMtime || mtime > latestMtime)) {
latestMtime = mtime;
}
}
if (aggregatedEntries.length === 0) {
continue;
}
aggregated.push({
kind,
relativePath: kind,
name: formatMemoryKindLabel(kind),
entries: aggregatedEntries,
sourceFiles,
extractedAt: latestMtime,
});
}
return aggregated;
}
/**
* Applies an inbox memory patch atomically and removes the patch on success.
*
* Process:
* 1. Parse + validate the patch headers (absolute paths only, no `a/`/`b/`).
* 2. Dry-run the patch against the current target content (or empty for
* `/dev/null` creation patches).
* 3. Stage the patched content to a temp file, then rename into place.
* 4. On any failure, restore previous content from the staged snapshot and
* leave the inbox patch intact for retry.
*/
/**
* Applies one inbox memory entry. Two modes:
* - Aggregate mode (`relativePath === kind`): walk every `.patch` file in
* the kind's inbox directory and apply each one in lexical order. Each
* file is its own atomic transaction; failures don't block subsequent
* successes. Returns an aggregated summary (e.g. "Applied 3 of 4 sub-
* patches; 1 failed: ").
* - Single-file mode (legacy): `relativePath` points at a specific
* `.patch` filename. Used by tests and direct callers.
*/
export async function applyInboxMemoryPatch(
config: Config,
kind: InboxMemoryPatchKind,
relativePath: string,
): Promise<{ success: boolean; message: string }> {
if (relativePath === kind) {
return applyAllInboxPatchesForKind(config, kind);
}
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
if (!normalizedPath) {
return { success: false, message: 'Invalid memory patch path.' };
}
const sourcePath = await getInboxMemoryPatchSourcePath(
config,
kind,
normalizedPath,
);
if (!sourcePath) {
return { success: false, message: 'Invalid memory patch path.' };
}
return applyMemoryPatchFile(config, kind, sourcePath, normalizedPath);
}
async function applyAllInboxPatchesForKind(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<{ success: boolean; message: string }> {
// Only attempt patches the user actually saw in the inbox listing.
// Files that were filtered (bad headers, escape allowed root, etc.) stay
// on disk untouched.
const patchFiles = await listValidInboxPatchFiles(config, kind);
if (patchFiles.length === 0) {
return {
success: false,
message: `No ${kind} memory patches in inbox.`,
};
}
const successes: string[] = [];
const failures: Array<{ name: string; reason: string }> = [];
let pointersAddedAcrossPatches: string[] = [];
for (const sourcePath of patchFiles) {
const basename = path.basename(sourcePath);
const result = await applyMemoryPatchFile(
config,
kind,
sourcePath,
basename,
);
if (result.success) {
successes.push(basename);
// Surface auto-added MEMORY.md pointer info if present.
const pointerMatch = result.message.match(
/Auto-added MEMORY\.md pointer for ([^.]+)\./,
);
if (pointerMatch) {
pointersAddedAcrossPatches.push(pointerMatch[1]);
}
} else {
failures.push({ name: basename, reason: result.message });
}
}
// De-dup pointer notes (same sibling could have been mentioned twice).
pointersAddedAcrossPatches = Array.from(new Set(pointersAddedAcrossPatches));
const total = successes.length + failures.length;
if (failures.length === 0) {
const pointerNote =
pointersAddedAcrossPatches.length > 0
? ` Auto-added MEMORY.md pointer(s) for ${pointersAddedAcrossPatches.join('; ')}.`
: '';
return {
success: true,
message: `Applied all ${successes.length} ${kind} memory patch${
successes.length === 1 ? '' : 'es'
}.${pointerNote}`,
};
}
const failureSummary = failures
.map((f) => `"${f.name}" — ${f.reason}`)
.join('; ');
// Any failure → success=false so the dialog keeps the inbox entry visible
// (the user needs to see and retry/dismiss the remaining sub-patches).
// The successful sub-patches have already been removed from disk by
// applyMemoryPatchFile, so the next listing will show only the failures.
return {
success: false,
message:
`Applied ${successes.length} of ${total} ${kind} memory patches. ` +
`${failures.length} failed: ${failureSummary}`,
};
}
async function canonicalizeDirIfPresent(dirPath: string): Promise<string> {
try {
return await fs.realpath(dirPath);
} catch {
return path.resolve(dirPath);
}
}
/**
* Returns the basenames of any sibling .md files (not MEMORY.md itself) that
* are being CREATED by this patch under `<memoryDir>/` directly.
*/
function findSiblingCreations(
appliedResults: readonly AppliedSkillPatchTarget[],
memoryDir: string,
): AppliedSkillPatchTarget[] {
return appliedResults.filter((entry) => {
if (!entry.isNewFile) return false;
const targetDir = path.dirname(path.resolve(entry.targetPath));
if (targetDir !== memoryDir) return false;
const basename = path.basename(entry.targetPath);
if (basename.toLowerCase() === 'memory.md') return false;
return basename.toLowerCase().endsWith('.md');
});
}
interface AutoPointerAugmentation {
/** Patch results, possibly with a synthesized/extended MEMORY.md entry. */
results: AppliedSkillPatchTarget[];
/** Sibling basenames a pointer was auto-added for (empty if none). */
pointersAdded: string[];
}
/**
* MEMORY.md is the index that gets injected into future agent contexts.
* Sibling .md files in `<memoryDir>/` are loaded ON DEMAND by the runtime
* agent via `read_file` but only IF MEMORY.md references them by name
* (see `getUserProjectMemoryPaths`).
*
* If a private patch creates a sibling without also referencing it from
* MEMORY.md, the new file would never be discoverable. Rather than rejecting
* the patch (bad UX), we auto-bundle a MEMORY.md update that adds a
* one-line pointer per orphan sibling. The augmented entry is then committed
* atomically alongside the rest of the patch.
*
* If the patch already updates/creates MEMORY.md and the new content already
* references the sibling, no augmentation is needed.
*/
async function augmentWithAutoPointers(
config: Config,
appliedResults: readonly AppliedSkillPatchTarget[],
): Promise<AutoPointerAugmentation> {
const memoryDir = await canonicalizeDirIfPresent(
config.storage.getProjectMemoryTempDir(),
);
const memoryMdPath = path.join(memoryDir, 'MEMORY.md');
const siblingCreations = findSiblingCreations(appliedResults, memoryDir);
if (siblingCreations.length === 0) {
return { results: [...appliedResults], pointersAdded: [] };
}
// Locate (or initialize) the MEMORY.md entry we'll mutate.
const existingIdx = appliedResults.findIndex(
(entry) => path.resolve(entry.targetPath) === memoryMdPath,
);
let memoryEntry: AppliedSkillPatchTarget;
if (existingIdx >= 0) {
memoryEntry = { ...appliedResults[existingIdx] };
} else {
let originalContent = '';
let isNewFile = true;
try {
originalContent = await fs.readFile(memoryMdPath, 'utf-8');
isNewFile = false;
} catch {
// MEMORY.md doesn't exist yet — we'll create it with a default heading.
}
memoryEntry = {
targetPath: memoryMdPath,
original: originalContent,
patched: isNewFile ? '# Project Memory\n' : originalContent,
isNewFile,
};
}
const pointersAdded: string[] = [];
for (const sibling of siblingCreations) {
const basename = path.basename(sibling.targetPath);
// Resolve to absolute path so the runtime agent can `read_file` the
// sibling directly without needing to know <memoryDir>.
const absoluteTarget = path.resolve(sibling.targetPath);
// Existing reference can be by either basename or absolute path; both count.
if (
memoryEntry.patched.includes(basename) ||
memoryEntry.patched.includes(absoluteTarget)
) {
continue; // Already referenced.
}
const stem = basename.replace(/\.md$/i, '').replace(/[-_]/g, ' ').trim();
const pointer = `- See ${absoluteTarget} for ${stem || basename} notes.`;
memoryEntry.patched = memoryEntry.patched.endsWith('\n')
? `${memoryEntry.patched}${pointer}\n`
: `${memoryEntry.patched}\n${pointer}\n`;
pointersAdded.push(basename);
}
if (pointersAdded.length === 0) {
return { results: [...appliedResults], pointersAdded: [] };
}
const results = [...appliedResults];
if (existingIdx >= 0) {
results[existingIdx] = memoryEntry;
} else {
results.push(memoryEntry);
}
return { results, pointersAdded };
}
/**
* Internal helper: parses, validates, and atomically commits a memory patch
* file at a known absolute path. Separated from `applyInboxMemoryPatch` so the
* path-resolution and patch-apply concerns stay testable independently.
*/
async function applyMemoryPatchFile(
config: Config,
kind: InboxMemoryPatchKind,
patchPath: string,
displayName: string,
): Promise<{ success: boolean; message: string }> {
let content: string;
try {
content = await fs.readFile(patchPath, 'utf-8');
} catch {
return {
success: false,
message: `Memory patch "${displayName}" not found in inbox.`,
};
}
let parsed: Diff.StructuredPatch[];
try {
parsed = Diff.parsePatch(content);
} catch (error) {
return {
success: false,
message: `Failed to parse memory patch "${displayName}": ${getErrorMessage(error)}`,
};
}
if (!hasParsedPatchHunks(parsed)) {
return {
success: false,
message: `Memory patch "${displayName}" contains no valid hunks.`,
};
}
const allowedRoots = await canonicalizeAllowedPatchRoots(
getAllowedMemoryPatchRoots(config, kind),
);
const applied = await applyParsedPatchesWithAllowedRoots(
parsed,
allowedRoots,
);
if (!applied.success) {
switch (applied.reason) {
case 'missingTargetPath':
return {
success: false,
message: `Memory patch "${displayName}" is missing a target file path.`,
};
case 'invalidPatchHeaders':
return {
success: false,
message: `Memory patch "${displayName}" has invalid diff headers.`,
};
case 'outsideAllowedRoots':
return {
success: false,
message: `Memory patch "${displayName}" targets a file outside the ${kind} memory root: ${applied.targetPath}`,
};
case 'newFileAlreadyExists':
return {
success: false,
message: `Memory patch "${displayName}" declares a new file, but the target already exists: ${applied.targetPath}`,
};
case 'targetNotFound':
return {
success: false,
message: `Target file not found: ${applied.targetPath}`,
};
case 'doesNotApply':
return {
success: false,
message: applied.isNewFile
? `Memory patch "${displayName}" failed to apply for new file ${applied.targetPath}.`
: `Memory patch does not apply cleanly to ${applied.targetPath}.`,
};
default:
return {
success: false,
message: `Memory patch "${displayName}" could not be applied.`,
};
}
}
// Auto-bundle a MEMORY.md pointer for any sibling .md the patch creates
// without referencing it from MEMORY.md. Without that pointer the new file
// would never be loaded into a future session (see augmentWithAutoPointers).
let pointersAdded: string[] = [];
let resultsToCommit: AppliedSkillPatchTarget[] = [...applied.results];
if (kind === 'private') {
const augmented = await augmentWithAutoPointers(config, applied.results);
resultsToCommit = augmented.results;
pointersAdded = augmented.pointersAdded;
}
let stagedTargets: StagedInboxPatchTarget[];
try {
stagedTargets = await stageInboxPatchTargets(resultsToCommit);
} catch (error) {
return {
success: false,
message: `Memory patch "${displayName}" could not be staged: ${getErrorMessage(error)}.`,
};
}
const committedTargets: StagedInboxPatchTarget[] = [];
try {
for (const stagedTarget of stagedTargets) {
await fs.rename(stagedTarget.tempPath, stagedTarget.targetPath);
committedTargets.push(stagedTarget);
}
} catch (error) {
for (const committedTarget of committedTargets.reverse()) {
try {
await restoreCommittedInboxPatchTarget(committedTarget);
} catch {
// Best-effort rollback. We still report the commit failure below.
}
}
await cleanupStagedInboxPatchTargets(
stagedTargets.filter((target) => !committedTargets.includes(target)),
);
return {
success: false,
message: `Memory patch "${displayName}" could not be applied atomically: ${getErrorMessage(error)}.`,
};
}
await fs.unlink(patchPath);
const fileCount = resultsToCommit.length;
const baseMessage = `Applied memory patch to ${fileCount} file${fileCount !== 1 ? 's' : ''}.`;
const pointerNote =
pointersAdded.length > 0
? ` Auto-added MEMORY.md pointer for ${pointersAdded
.map((name) => `"${name}"`)
.join(', ')} so the new sibling file is discoverable.`
: '';
return {
success: true,
message: `${baseMessage}${pointerNote}`,
};
}
/**
* Removes inbox memory patch(es) without applying. Two modes:
* - Aggregate (`relativePath === kind`): unlink every `.patch` file in the
* kind's inbox directory. Used by the consolidated inbox UI's Dismiss.
* - Single-file (legacy): unlink one specific `.patch` file.
*/
export async function dismissInboxMemoryPatch(
config: Config,
kind: InboxMemoryPatchKind,
relativePath: string,
): Promise<{ success: boolean; message: string }> {
if (relativePath === kind) {
// Dismiss the same set of files the listing surfaced — leave the
// already-filtered (bad-target, malformed) files alone for forensic
// inspection.
const patchFiles = await listValidInboxPatchFiles(config, kind);
if (patchFiles.length === 0) {
return {
success: false,
message: `No ${kind} memory patches in inbox.`,
};
}
let removed = 0;
for (const sourcePath of patchFiles) {
try {
await fs.unlink(sourcePath);
removed += 1;
} catch {
// Best-effort: keep going if one delete fails.
}
}
return {
success: removed > 0,
message: `Dismissed ${removed} ${kind} memory patch${
removed === 1 ? '' : 'es'
} from inbox.`,
};
}
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
if (!normalizedPath) {
return { success: false, message: 'Invalid memory patch path.' };
}
const sourcePath = await getInboxMemoryPatchSourcePath(
config,
kind,
normalizedPath,
);
if (!sourcePath) {
return { success: false, message: 'Invalid memory patch path.' };
}
try {
await fs.access(sourcePath);
} catch {
return {
success: false,
message: `Memory patch "${normalizedPath}" not found in inbox.`,
};
}
await fs.unlink(sourcePath);
return {
success: true,
message: `Dismissed "${normalizedPath}" from inbox.`,
};
}
async function findNearestExistingDirectory(
startPath: string,
): Promise<string> {
+166
View File
@@ -72,6 +72,10 @@ import {
} from './models.js';
import { Storage } from './storage.js';
import type { AgentLoopContext } from './agent-loop-context.js';
import {
runWithScopedAutoMemoryExtractionWriteAccess,
runWithScopedMemoryInboxAccess,
} from './scoped-config.js';
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
@@ -3656,6 +3660,168 @@ describe('Config JIT Initialization', () => {
config.isPathAllowed(path.join(globalDir, 'oauth_creds.json')),
).toBe(false);
});
it('should NOT allow isPathAllowed to write into the auto-memory inbox', () => {
// <projectMemoryDir>/.inbox/ is owned by the extraction agent and the
// /memory inbox review flow. The main agent must not be able to drop
// patches in there directly, even though it falls inside <projectTempDir>.
// We bypass Config.initialize() (the GitService init path is independently
// flaky in this suite) by spying on the storage methods isPathAllowed
// actually consults.
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
};
config = new Config(params);
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
const fakeProjectTempDir = '/tmp/test-fake-temp';
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
fakeMemoryTempDir,
);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
fakeProjectTempDir,
);
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
// The inbox directory itself and any path under it are denied.
expect(config.isPathAllowed(inboxRoot)).toBe(false);
expect(
config.isPathAllowed(path.join(inboxRoot, 'private', 'foo.patch')),
).toBe(false);
expect(
config.isPathAllowed(path.join(inboxRoot, 'global', 'bar.patch')),
).toBe(false);
// Sibling files under <projectMemoryDir> stay reachable so the main
// agent can edit MEMORY.md and topic notes directly.
expect(
config.isPathAllowed(path.join(fakeMemoryTempDir, 'MEMORY.md')),
).toBe(true);
expect(
config.isPathAllowed(path.join(fakeMemoryTempDir, 'some-topic.md')),
).toBe(true);
});
it('should allow scoped extraction access only to canonical inbox patches', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
};
config = new Config(params);
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
const fakeProjectTempDir = '/tmp/test-fake-temp';
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
fakeMemoryTempDir,
);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
fakeProjectTempDir,
);
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
const privateExtractionPatch = path.join(
inboxRoot,
'private',
'extraction.patch',
);
const globalExtractionPatch = path.join(
inboxRoot,
'global',
'extraction.patch',
);
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
runWithScopedMemoryInboxAccess(() => {
expect(config.isPathAllowed(privateExtractionPatch)).toBe(true);
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
expect(config.isPathAllowed(globalExtractionPatch)).toBe(true);
expect(
config.isPathAllowed(path.join(inboxRoot, 'private', 'other.patch')),
).toBe(false);
expect(
config.isPathAllowed(
path.join(inboxRoot, 'private', 'nested', 'extraction.patch'),
),
).toBe(false);
});
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
});
it('should restrict scoped auto-memory extraction writes to generated artifacts', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
};
config = new Config(params);
const fakeMemoryTempDir = '/tmp/test-fake-temp/memory';
const fakeProjectTempDir = '/tmp/test-fake-temp';
const fakeSkillsMemoryDir = path.join(fakeMemoryTempDir, 'skills');
vi.spyOn(config.storage, 'getProjectMemoryTempDir').mockReturnValue(
fakeMemoryTempDir,
);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue(
fakeProjectTempDir,
);
vi.spyOn(config.storage, 'getProjectSkillsMemoryDir').mockReturnValue(
fakeSkillsMemoryDir,
);
const inboxRoot = path.join(fakeMemoryTempDir, '.inbox');
const privateExtractionPatch = path.join(
inboxRoot,
'private',
'extraction.patch',
);
const skillArtifact = path.join(
fakeSkillsMemoryDir,
'my-skill',
'SKILL.md',
);
const activeMemoryPath = path.join(fakeMemoryTempDir, 'MEMORY.md');
const projectTempPath = path.join(fakeProjectTempDir, 'logs', 'run.log');
const workspaceMemoryPath = path.join('/tmp/test', 'GEMINI.md');
expect(config.validatePathAccess(activeMemoryPath)).toBeNull();
runWithScopedAutoMemoryExtractionWriteAccess(() => {
expect(config.validatePathAccess(skillArtifact)).toBeNull();
expect(config.validatePathAccess(activeMemoryPath)).toContain(
'Auto-memory extraction write denied',
);
expect(config.validatePathAccess(projectTempPath)).toContain(
'Auto-memory extraction write denied',
);
expect(config.validatePathAccess(workspaceMemoryPath)).toContain(
'Auto-memory extraction write denied',
);
// Reads still use the normal workspace/temp allowlists.
expect(config.validatePathAccess(activeMemoryPath, 'read')).toBeNull();
});
runWithScopedMemoryInboxAccess(() => {
runWithScopedAutoMemoryExtractionWriteAccess(() => {
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
});
});
});
});
describe('isAutoMemoryEnabled', () => {
+100 -1
View File
@@ -140,7 +140,11 @@ import type { GenerateContentParameters } from '@google/genai';
export type { MCPOAuthConfig, AnyToolInvocation, AnyDeclarativeTool };
import type { AnyToolInvocation, AnyDeclarativeTool } from '../tools/tools.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import { getWorkspaceContextOverride } from './scoped-config.js';
import {
getWorkspaceContextOverride,
hasScopedAutoMemoryExtractionWriteAccess,
hasScopedMemoryInboxAccess,
} from './scoped-config.js';
import { Storage } from './storage.js';
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
import { FileExclusions } from '../utils/ignorePatterns.js';
@@ -3063,6 +3067,52 @@ export class Config implements McpContext, AgentLoopContext {
this.ideMode = value;
}
private isScopedMemoryInboxPatchPathAllowed(
absolutePath: string,
resolvedPath: string,
inboxRoot: string,
): boolean {
if (!hasScopedMemoryInboxAccess()) {
return false;
}
const normalizedPath = path.resolve(absolutePath);
const isCanonicalPatchPath = (['private', 'global'] as const).some(
(kind) =>
normalizedPath === path.resolve(inboxRoot, kind, 'extraction.patch'),
);
if (!isCanonicalPatchPath) {
return false;
}
const resolvedMemoryRoot = resolveToRealPath(
this.storage.getProjectMemoryTempDir(),
);
return isSubpath(resolvedMemoryRoot, resolvedPath);
}
private isScopedAutoMemoryExtractionWritePathAllowed(
absolutePath: string,
resolvedPath: string,
): boolean {
if (!hasScopedAutoMemoryExtractionWriteAccess()) {
return false;
}
const resolvedSkillsMemoryDir = resolveToRealPath(
this.storage.getProjectSkillsMemoryDir(),
);
if (isSubpath(resolvedSkillsMemoryDir, resolvedPath)) {
return true;
}
return this.isScopedMemoryInboxPatchPathAllowed(
absolutePath,
resolvedPath,
path.join(this.storage.getProjectMemoryTempDir(), '.inbox'),
);
}
/**
* Get the current FileSystemService
*/
@@ -3077,12 +3127,48 @@ export class Config implements McpContext, AgentLoopContext {
* file (the latter is the only file under `~/.gemini/` that is reachable
* settings, credentials, keybindings, etc. remain disallowed).
*
* One subtree is *carved back out*: `<projectMemoryDir>/.inbox/` is owned by
* the auto-memory extraction agent and the `/memory inbox` review flow. The
* main agent is denied access to it even though it falls inside the project
* temp dir; the extraction agent receives a narrow execution-scoped exception
* for `.inbox/{private,global}/extraction.patch`.
*
* @param absolutePath The absolute path to check.
* @returns true if the path is allowed, false otherwise.
*/
isPathAllowed(absolutePath: string): boolean {
const resolvedPath = resolveToRealPath(absolutePath);
// The auto-memory inbox (`<projectMemoryDir>/.inbox/`) is owned by the
// background extraction agent and the `/memory inbox` review flow. The
// main agent must NOT drop files into it directly (that would let the
// model bypass review). Deny first, even if the path also satisfies the
// workspace or project-temp allowlists below.
const inboxRoot = path.join(
this.storage.getProjectMemoryTempDir(),
'.inbox',
);
const resolvedInboxRoot = resolveToRealPath(inboxRoot);
const normalizedPath = path.resolve(absolutePath);
const normalizedInboxRoot = path.resolve(inboxRoot);
if (
resolvedPath === resolvedInboxRoot ||
isSubpath(resolvedInboxRoot, resolvedPath) ||
normalizedPath === normalizedInboxRoot ||
isSubpath(normalizedInboxRoot, normalizedPath)
) {
if (
this.isScopedMemoryInboxPatchPathAllowed(
absolutePath,
resolvedPath,
inboxRoot,
)
) {
return true;
}
return false;
}
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(resolvedPath)) {
return true;
@@ -3122,6 +3208,19 @@ export class Config implements McpContext, AgentLoopContext {
absolutePath: string,
checkType: 'read' | 'write' = 'write',
): string | null {
if (checkType === 'write' && hasScopedAutoMemoryExtractionWriteAccess()) {
const resolvedPath = resolveToRealPath(absolutePath);
if (
this.isScopedAutoMemoryExtractionWritePathAllowed(
absolutePath,
resolvedPath,
)
) {
return null;
}
return `Auto-memory extraction write denied: Attempted path "${absolutePath}" is outside the extraction write allowlist. Extraction may only write extracted skills under ${this.storage.getProjectSkillsMemoryDir()} and canonical inbox patches under ${path.join(this.storage.getProjectMemoryTempDir(), '.inbox', '{private,global}', 'extraction.patch')}.`;
}
// For read operations, check read-only paths first
if (checkType === 'read') {
if (this.getWorkspaceContext().isPathReadable(absolutePath)) {
+39
View File
@@ -19,6 +19,9 @@ import { WorkspaceContext } from '../utils/workspaceContext.js';
* This follows the same pattern as `toolCallContext` and `promptIdContext`.
*/
const workspaceContextOverride = new AsyncLocalStorage<WorkspaceContext>();
const memoryInboxAccessOverride = new AsyncLocalStorage<boolean>();
const autoMemoryExtractionWriteAccessOverride =
new AsyncLocalStorage<boolean>();
/**
* Returns the current workspace context override, if any.
@@ -44,6 +47,42 @@ export function runWithScopedWorkspaceContext<T>(
return workspaceContextOverride.run(scopedContext, fn);
}
/**
* Returns true when the current async execution is allowed to access the
* canonical auto-memory inbox patch files.
*/
export function hasScopedMemoryInboxAccess(): boolean {
return memoryInboxAccessOverride.getStore() === true;
}
/**
* Runs a function with access to the canonical auto-memory inbox patch files.
* This is intended for the background extraction agent only; the main agent
* continues to have the inbox carved out of its normal temp-dir access.
*/
export function runWithScopedMemoryInboxAccess<T>(fn: () => T): T {
return memoryInboxAccessOverride.run(true, fn);
}
/**
* Returns true when the current async execution is using the narrow
* auto-memory extraction write allowlist.
*/
export function hasScopedAutoMemoryExtractionWriteAccess(): boolean {
return autoMemoryExtractionWriteAccessOverride.getStore() === true;
}
/**
* Runs a function with the auto-memory extraction write allowlist active.
* This prevents the background extractor from writing active memory files
* directly; it may only write extracted skills and canonical inbox patches.
*/
export function runWithScopedAutoMemoryExtractionWriteAccess<T>(
fn: () => T,
): T {
return autoMemoryExtractionWriteAccessOverride.run(true, fn);
}
/**
* Creates a {@link WorkspaceContext} that extends a parent's directories
* with additional ones.
-4
View File
@@ -106,10 +106,6 @@ export class Storage {
return path.join(Storage.getGlobalAgentsDir(), 'skills');
}
static getGlobalMemoryFilePath(): string {
return path.join(Storage.getGlobalGeminiDir(), 'memory.md');
}
static getUserPoliciesDir(): string {
return path.join(Storage.getGlobalGeminiDir(), 'policies');
}
+3 -2
View File
@@ -78,6 +78,7 @@ export const generalistProfile: ContextProfile = {
budget: {
retainedTokens: 65000,
maxTokens: 150000,
coalescingThresholdTokens: 5000,
},
},
@@ -117,14 +118,14 @@ export const generalistProfile: ContextProfile = {
'NodeDistillation',
env,
resolveProcessorOptions(config, 'NodeDistillation', {
nodeThresholdTokens: 1000,
nodeThresholdTokens: 3000,
}),
),
createNodeTruncationProcessor(
'NodeTruncation',
env,
resolveProcessorOptions(config, 'NodeTruncation', {
maxTokensPerNode: 1200,
maxTokensPerNode: 4000,
}),
),
],
@@ -42,6 +42,11 @@ export function getContextManagementConfigSchema(
description:
'The absolute maximum token count allowed before synchronous truncation kicks in.',
},
coalescingThresholdTokens: {
type: 'number',
description:
'Only trigger background consolidation (snapshots) when at least this many tokens have aged out. Prevents "turn-by-turn" utility model churn.',
},
},
},
processorOptions: {
@@ -29,6 +29,11 @@ export interface AsyncPipelineDef {
export interface ContextBudget {
retainedTokens: number;
maxTokens: number;
/**
* Only trigger background consolidation (snapshots) when at least this many
* tokens have aged out. Prevents "turn-by-turn" utility model churn.
*/
coalescingThresholdTokens?: number;
}
/**
+17 -9
View File
@@ -141,15 +141,23 @@ export class ContextManager {
}
if (agedOutNodes.size > 0) {
this.env.tokenCalculator.garbageCollectCache(
new Set(this.buffer.nodes.map((n) => n.id)),
);
this.eventBus.emitConsolidationNeeded({
nodes: this.buffer.nodes,
targetDeficit:
currentTokens - this.sidecar.config.budget.retainedTokens,
targetNodeIds: agedOutNodes,
});
const targetDeficit =
currentTokens - this.sidecar.config.budget.retainedTokens;
// Respect coalescing threshold for background work
const threshold =
this.sidecar.config.budget.coalescingThresholdTokens || 0;
if (targetDeficit >= threshold) {
this.env.tokenCalculator.garbageCollectCache(
new Set(this.buffer.nodes.map((n) => n.id)),
);
this.eventBus.emitConsolidationNeeded({
nodes: this.buffer.nodes,
targetDeficit,
targetNodeIds: agedOutNodes,
});
}
}
}
}
@@ -17,9 +17,9 @@ export class SnapshotGenerator {
const systemPrompt =
systemInstruction ??
`You are an expert Context Memory Manager. You will be provided with a raw transcript of older conversation turns between a user and an AI assistant.
Your task is to synthesize these turns into a single, dense, factual snapshot that preserves all critical context, preferences, active tasks, and factual knowledge, but discards conversational filler, pleasantries, and redundant back-and-forth iterations.
Your task is to synthesize these turns into a single, dense, factual snapshot that preserves all critical context, preferences, active tasks, and factual knowledge.
Output ONLY the raw factual snapshot, formatted compactly. Do not include markdown wrappers, prefixes like "Here is the snapshot", or conversational elements.`;
Discard conversational filler, pleasantries, and redundant back-and-forth iterations. Output ONLY the raw factual snapshot, formatted compactly. Do not include markdown wrappers, prefixes like "Here is the snapshot", or conversational elements.`;
let userPromptText = 'TRANSCRIPT TO SNAPSHOT:\n\n';
for (const node of nodes) {
+1
View File
@@ -289,6 +289,7 @@ describe('Gemini Client (client.ts)', () => {
resetTurn: vi.fn(),
isAutoDistillationEnabled: vi.fn().mockReturnValue(false),
isContextManagementEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
getModelAvailabilityService: vi
.fn()
+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 = {
+56 -2
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[],
@@ -776,7 +827,10 @@ export class GeminiChat {
const history = curated
? extractCuratedHistory([...this.agentHistory.get()])
: this.agentHistory.get();
return [...history];
return this.context.config.isContextManagementEnabled()
? scrubHistory([...history])
: [...history];
}
/**
@@ -587,4 +587,66 @@ describe('GeminiChat Network Retries', () => {
}),
);
});
it('should retry on premature stream closure (ERR_STREAM_PREMATURE_CLOSE)', async () => {
mockConfig.getRetryFetchErrors = vi.fn().mockReturnValue(true);
const prematureCloseError = new Error('Premature close');
Object.defineProperty(prematureCloseError, 'code', {
value: 'ERR_STREAM_PREMATURE_CLOSE',
});
vi.mocked(mockContentGenerator.generateContentStream)
.mockResolvedValueOnce(
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Incomplete part' }] } }],
} as unknown as GenerateContentResponse;
throw prematureCloseError;
})(),
)
.mockResolvedValueOnce(
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Complete response after retry' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
{ model: 'test-model' },
'test message',
'prompt-id-premature-close',
new AbortController().signal,
LlmRole.MAIN,
);
const events: StreamEvent[] = [];
for await (const event of stream) {
events.push(event);
}
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
expect(retryEvent).toBeDefined();
const successChunk = events.find(
(e) =>
e.type === StreamEventType.CHUNK &&
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
'Complete response after retry',
);
expect(successChunk).toBeDefined();
expect(mockLogNetworkRetryAttempt).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
error_type: 'ERR_STREAM_PREMATURE_CLOSE',
}),
);
});
});
@@ -1898,6 +1898,30 @@ describe('PolicyEngine', () => {
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should NOT downgrade to ASK_USER for redirected commands in YOLO mode even without sandbox', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: 10,
},
];
engine = new PolicyEngine({
rules,
approvalMode: ApprovalMode.YOLO,
sandboxManager: new NoopSandboxManager(),
});
const command = 'npm test 2>&1 | tail -80';
const { decision } = await engine.check(
{ name: 'run_shell_command', args: { command } },
undefined,
);
expect(decision).toBe(PolicyDecision.ALLOW);
});
it('should return ALLOW in YOLO mode even if shell command parsing fails', async () => {
const { splitCommands } = await import('../utils/shell-utils.js');
const rules: PolicyRule[] = [
+4 -5
View File
@@ -288,12 +288,11 @@ export class PolicyEngine {
if (allowRedirection) return false;
if (!hasRedirection(command)) return false;
// Do not downgrade (do not ask user) if sandboxing is enabled and in AUTO_EDIT or YOLO
const sandboxEnabled = !(this.sandboxManager instanceof NoopSandboxManager);
// Do not downgrade (do not ask user) if in AUTO_EDIT or YOLO mode.
// These modes trust the agent's actions (YOLO) or specific task (AUTO_EDIT).
if (
sandboxEnabled &&
(this.approvalMode === ApprovalMode.AUTO_EDIT ||
this.approvalMode === ApprovalMode.YOLO)
this.approvalMode === ApprovalMode.AUTO_EDIT ||
this.approvalMode === ApprovalMode.YOLO
) {
return false;
}
+66 -2
View File
@@ -247,6 +247,27 @@ export type ApplyParsedSkillPatchesResult =
export async function applyParsedSkillPatches(
parsedPatches: StructuredPatch[],
config: Config,
): Promise<ApplyParsedSkillPatchesResult> {
const allowedRoots = await getCanonicalAllowedSkillPatchRoots(config);
return applyParsedPatchesWithAllowedRoots(parsedPatches, allowedRoots);
}
/**
* Applies parsed unified diff patches against any caller-supplied set of
* allowed root directories. This is the kind-agnostic core used by both the
* skill patch flow and the memory patch flow.
*
* The patch headers must reference absolute paths inside one of the allowed
* roots (after canonical resolution). Update patches must reference an
* existing target; creation patches (`/dev/null` source) must reference a path
* that does not yet exist.
*
* Returns the per-target before/after content so callers can stage commits
* and roll back on failure.
*/
export async function applyParsedPatchesWithAllowedRoots(
parsedPatches: StructuredPatch[],
allowedRoots: string[],
): Promise<ApplyParsedSkillPatchesResult> {
const results = new Map<string, AppliedSkillPatchTarget>();
const patchedContentByTarget = new Map<string, string>();
@@ -260,9 +281,9 @@ export async function applyParsedSkillPatches(
for (const [index, patch] of parsedPatches.entries()) {
const { targetPath, isNewFile } = validatedHeaders.patches[index];
const resolvedTargetPath = await resolveAllowedSkillPatchTarget(
const resolvedTargetPath = await resolveTargetWithinAllowedRoots(
targetPath,
config,
allowedRoots,
);
if (!resolvedTargetPath) {
return {
@@ -337,3 +358,46 @@ export async function applyParsedSkillPatches(
results: Array.from(results.values()),
};
}
/**
* Canonicalizes a caller-supplied allowed root list once so callers can pass
* raw `Storage` paths without each call doing realpath traversal.
*/
export async function canonicalizeAllowedPatchRoots(
roots: string[],
): Promise<string[]> {
const canonicalRoots = await Promise.all(
roots.map((root) => resolvePathWithExistingAncestors(root)),
);
return Array.from(
new Set(
canonicalRoots.filter((root): root is string => typeof root === 'string'),
),
);
}
/**
* Returns the canonical target path if it falls inside (or exactly equals)
* one of the supplied allowed roots, otherwise `undefined`. Allowed roots may
* be either directories (subtree allowlist) or single file paths
* (single-file allowlist) `isSubpath(file, file)` returns true for the
* same-path case.
*
* Exported so that `listInboxMemoryPatches` can pre-filter patches whose
* headers escape the kind's allowed root, instead of surfacing them in the
* UI just to fail at Apply time.
*/
export async function resolveTargetWithinAllowedRoots(
targetPath: string,
allowedRoots: string[],
): Promise<string | undefined> {
const canonicalTargetPath =
await resolvePathWithExistingAncestors(targetPath);
if (!canonicalTargetPath) {
return undefined;
}
if (allowedRoots.some((root) => isSubpath(root, canonicalTargetPath))) {
return canonicalTargetPath;
}
return undefined;
}
@@ -74,6 +74,7 @@ vi.mock('../agents/registry.js', () => ({
vi.mock('../config/storage.js', () => ({
Storage: {
getUserSkillsDir: vi.fn().mockReturnValue('/tmp/fake-user-skills'),
getGlobalGeminiDir: vi.fn().mockReturnValue('/tmp/fake-global-gemini'),
},
}));
@@ -566,6 +567,109 @@ describe('memoryService', () => {
);
});
it('records inbox patches as memoryCandidatesCreated without applying them', async () => {
const { startMemoryService, readExtractionState } = await import(
'./memoryService.js'
);
const { LocalAgentExecutor } = await import(
'../agents/local-executor.js'
);
vi.mocked(coreEvents.emitFeedback).mockClear();
vi.mocked(LocalAgentExecutor.create).mockReset();
const memoryDir = path.join(tmpDir, 'memory-inbox-only');
const skillsDir = path.join(tmpDir, 'skills-inbox-only');
const projectTempDir = path.join(tmpDir, 'temp-inbox-only');
const chatsDir = path.join(projectTempDir, 'chats');
await fs.mkdir(memoryDir, { recursive: true });
await fs.mkdir(skillsDir, { recursive: true });
await fs.mkdir(chatsDir, { recursive: true });
const conversation = createConversation({
sessionId: 'inbox-only-session',
messageCount: 20,
});
await fs.writeFile(
path.join(chatsDir, 'session-2025-01-01T00-00-inbox001.json'),
JSON.stringify(conversation),
);
vi.mocked(LocalAgentExecutor.create).mockResolvedValueOnce({
run: vi.fn().mockImplementation(async () => {
const inboxDir = path.join(memoryDir, '.inbox');
await fs.mkdir(path.join(inboxDir, 'private'), { recursive: true });
await fs.mkdir(path.join(inboxDir, 'global'), { recursive: true });
await fs.writeFile(
path.join(inboxDir, 'private', 'MEMORY.patch'),
[
`--- /dev/null`,
`+++ ${path.join(memoryDir, 'MEMORY.md')}`,
`@@ -0,0 +1,1 @@`,
`+- new project fact`,
``,
].join('\n'),
);
await fs.writeFile(
path.join(inboxDir, 'global', 'reply-style.patch'),
[
`--- /dev/null`,
`+++ /workspace/global/GEMINI.md`,
`@@ -0,0 +1,1 @@`,
`+Prefer concise architecture summaries.`,
``,
].join('\n'),
);
return undefined;
}),
} as never);
const mockConfig = {
storage: {
getProjectMemoryDir: vi.fn().mockReturnValue(memoryDir),
getProjectMemoryTempDir: vi.fn().mockReturnValue(memoryDir),
getProjectSkillsMemoryDir: vi.fn().mockReturnValue(skillsDir),
getProjectTempDir: vi.fn().mockReturnValue(projectTempDir),
},
getToolRegistry: vi.fn(),
getMessageBus: vi.fn(),
getGeminiClient: vi.fn(),
getSkillManager: vi.fn().mockReturnValue({ getSkills: () => [] }),
modelConfigService: {
registerRuntimeModelConfig: vi.fn(),
},
sandboxManager: undefined,
} as unknown as Parameters<typeof startMemoryService>[0];
await startMemoryService(mockConfig);
// No patch was applied — active files do not exist.
await expect(
fs.access(path.join(memoryDir, 'MEMORY.md')),
).rejects.toThrow();
// Both patches remain in inbox awaiting review.
for (const relativePath of [
path.join('.inbox', 'private', 'MEMORY.patch'),
path.join('.inbox', 'global', 'reply-style.patch'),
]) {
await expect(
fs.access(path.join(memoryDir, relativePath)),
).resolves.toBeUndefined();
}
const state = await readExtractionState(
path.join(memoryDir, '.extraction-state.json'),
);
expect(state.runs.at(-1)?.memoryFilesUpdated ?? []).toEqual([]);
expect(state.runs.at(-1)?.memoryCandidatesCreated ?? []).toEqual(
expect.arrayContaining([
path.join('.inbox', 'private', 'MEMORY.patch'),
path.join('.inbox', 'global', 'reply-style.patch'),
]),
);
});
it('records only sessions whose read_file completed successfully as processed', async () => {
const { startMemoryService, readExtractionState } = await import(
'./memoryService.js'
+260 -11
View File
@@ -6,7 +6,7 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { constants as fsConstants } from 'node:fs';
import { constants as fsConstants, type Dirent } from 'node:fs';
import { randomUUID } from 'node:crypto';
import * as Diff from 'diff';
import type { Config } from '../config/config.js';
@@ -45,6 +45,11 @@ import { sanitizeWorkflowSummaryForScratchpad } from './sessionScratchpadUtils.j
const LOCK_FILENAME = '.extraction.lock';
const STATE_FILENAME = '.extraction-state.json';
const LOCK_STALE_MS = 35 * 60 * 1000; // 35 minutes (exceeds agent's 30-min time limit)
// Throttle: skip background extraction if the most recent run finished less
// than this long ago. Pairs with the advisory lock — the lock prevents
// concurrent runs; this throttle prevents back-to-back runs across short
// CLI sessions on workspaces with a lot of session history.
const MIN_EXTRACTION_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
const MIN_USER_MESSAGES = 10;
const MIN_IDLE_MS = 3 * 60 * 60 * 1000; // 3 hours
const MAX_SESSION_INDEX_SIZE = 50;
@@ -78,6 +83,8 @@ export interface ExtractionRun {
sessionIds: string[];
candidateSessions?: SessionVersion[];
processedSessions?: SessionVersion[];
memoryCandidatesCreated?: string[];
memoryFilesUpdated?: string[];
skillsCreated: string[];
turnCount?: number;
durationMs?: number;
@@ -163,6 +170,8 @@ function isExtractionRunLike(value: unknown): value is {
sessionIds?: unknown;
candidateSessions?: unknown;
processedSessions?: unknown;
memoryCandidatesCreated?: unknown;
memoryFilesUpdated?: unknown;
skillsCreated: unknown;
turnCount?: unknown;
durationMs?: unknown;
@@ -194,22 +203,44 @@ function buildExtractionRun(value: unknown): ExtractionRun | null {
const candidateSessions = normalizeSessionVersions(value.candidateSessions);
const processedSessions = normalizeSessionVersions(value.processedSessions);
const sessionIds = normalizeStringArray(value.sessionIds);
return {
const run: ExtractionRun = {
runAt: value.runAt,
sessionIds:
sessionIds.length > 0
? sessionIds
: processedSessions.map((session) => session.sessionId),
candidateSessions:
candidateSessions.length > 0 ? candidateSessions : undefined,
processedSessions:
processedSessions.length > 0 ? processedSessions : undefined,
skillsCreated: normalizeStringArray(value.skillsCreated),
turnCount: normalizeOptionalNumber(value.turnCount),
durationMs: normalizeOptionalNumber(value.durationMs),
terminateReason: normalizeOptionalString(value.terminateReason),
};
if (candidateSessions.length > 0) {
run.candidateSessions = candidateSessions;
}
if (processedSessions.length > 0) {
run.processedSessions = processedSessions;
}
if ('memoryCandidatesCreated' in value) {
run.memoryCandidatesCreated = normalizeStringArray(
value.memoryCandidatesCreated,
);
}
if ('memoryFilesUpdated' in value) {
run.memoryFilesUpdated = normalizeStringArray(value.memoryFilesUpdated);
}
const turnCount = normalizeOptionalNumber(value.turnCount);
if (turnCount !== undefined) {
run.turnCount = turnCount;
}
const durationMs = normalizeOptionalNumber(value.durationMs);
if (durationMs !== undefined) {
run.durationMs = durationMs;
}
const terminateReason = normalizeOptionalString(value.terminateReason);
if (terminateReason !== undefined) {
run.terminateReason = terminateReason;
}
return run;
}
function getTimestampMs(timestamp: string): number {
@@ -897,6 +928,164 @@ export async function validatePatches(
return validPatches;
}
type FileSnapshot = Map<string, string>;
async function snapshotFiles(
rootDir: string,
shouldIncludeFile: (relativePath: string) => boolean = () => true,
shouldDescendDirectory: (relativePath: string) => boolean = () => true,
): Promise<FileSnapshot> {
const snapshot: FileSnapshot = new Map();
async function walk(currentDir: string): Promise<void> {
let entries: Array<Dirent<string>>;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const absolutePath = path.join(currentDir, entry.name);
const relativePath = path.relative(rootDir, absolutePath);
if (!relativePath) {
continue;
}
if (entry.isDirectory()) {
if (shouldDescendDirectory(relativePath)) {
await walk(absolutePath);
}
continue;
}
if (!entry.isFile() || !shouldIncludeFile(relativePath)) {
continue;
}
try {
snapshot.set(relativePath, await fs.readFile(absolutePath, 'utf-8'));
} catch {
// Best-effort snapshot: ignore files that disappear or are unreadable.
}
}
}
await walk(rootDir);
return snapshot;
}
async function snapshotInboxCandidates(
memoryDir: string,
): Promise<FileSnapshot> {
return snapshotFiles(path.join(memoryDir, '.inbox'));
}
/**
* Builds a human-readable summary of the current memory inbox state, grouped
* by kind and showing the contents of each `.patch` file. Used as part of the
* extraction agent's initial context so the agent can extend existing
* canonical patches in-place rather than creating new files each session.
*
* Returns an empty string if the inbox is empty.
*/
async function buildPendingInboxSummary(memoryDir: string): Promise<string> {
const sections: string[] = [];
for (const kind of ['private', 'global'] as const) {
const kindRoot = path.join(memoryDir, '.inbox', kind);
let entries: Array<Dirent<string>>;
try {
entries = await fs.readdir(kindRoot, { withFileTypes: true });
} catch {
continue;
}
const patchFiles = entries
.filter((e) => e.isFile() && e.name.endsWith('.patch'))
.map((e) => e.name)
.sort();
if (patchFiles.length === 0) {
continue;
}
const filesSection: string[] = [`## ${kind} (${patchFiles.length})`];
for (const fileName of patchFiles) {
const fullPath = path.join(kindRoot, fileName);
let content = '';
try {
content = await fs.readFile(fullPath, 'utf-8');
} catch {
continue;
}
// Guard against indirect prompt injection: patch contents originate
// from past sessions (which may include user-pasted text), so a
// crafted payload could include a closing ``` fence to break out of
// the surrounding markdown block. Pick a fence longer than the
// longest backtick-run actually present in the content so the close
// is guaranteed to terminate the block.
const longestBacktickRun = (content.match(/`+/g) ?? []).reduce(
(max, run) => Math.max(max, run.length),
2, // never go below the standard 3-backtick fence
);
const fence = '`'.repeat(longestBacktickRun + 1);
filesSection.push('');
filesSection.push(`### ${fileName}`);
filesSection.push(fence);
filesSection.push(content.trimEnd());
filesSection.push(fence);
}
sections.push(filesSection.join('\n'));
}
return sections.join('\n\n');
}
interface FileSnapshotDiff {
added: string[];
updated: string[];
deleted: string[];
}
function diffFileSnapshots(
before: FileSnapshot,
after: FileSnapshot,
): FileSnapshotDiff {
const added: string[] = [];
const updated: string[] = [];
const deleted: string[] = [];
for (const [relativePath, content] of after) {
if (!before.has(relativePath)) {
added.push(relativePath);
} else if (before.get(relativePath) !== content) {
updated.push(relativePath);
}
}
for (const relativePath of before.keys()) {
if (!after.has(relativePath)) {
deleted.push(relativePath);
}
}
return {
added: added.sort(),
updated: updated.sort(),
deleted: deleted.sort(),
};
}
function getChangedSnapshotPaths(diff: FileSnapshotDiff): string[] {
return [...diff.added, ...diff.updated].sort();
}
function prefixRelativePaths(
prefix: string,
relativePaths: string[],
): string[] {
return relativePaths.map((relativePath) => path.join(prefix, relativePath));
}
/**
* Main entry point for the skill extraction background task.
* Designed to be called fire-and-forget on session startup.
@@ -947,6 +1136,24 @@ export async function startMemoryService(config: Config): Promise<void> {
`[MemoryService] State loaded: ${previousRuns} previous run(s), ${previouslyProcessed} session(s) already processed`,
);
// Throttle: short-circuit if the most recent run finished less than
// MIN_EXTRACTION_INTERVAL_MS ago. Avoids re-scanning session history on
// every CLI start when the user opens several short sessions in a row.
const lastRun = state.runs.at(-1);
if (lastRun?.runAt) {
const lastRunMs = Date.parse(lastRun.runAt);
if (
Number.isFinite(lastRunMs) &&
Date.now() - lastRunMs < MIN_EXTRACTION_INTERVAL_MS
) {
const minutesAgo = Math.round((Date.now() - lastRunMs) / 60000);
debugLogger.log(
`[MemoryService] Skipped: last run was ${minutesAgo} minute(s) ago (min interval ${MIN_EXTRACTION_INTERVAL_MS / 60000}m)`,
);
return;
}
}
// Build session index: all eligible sessions with summaries + file paths.
// The agent decides which to read in full via read_file.
const { sessionIndex, newSessionIds, candidateSessions } =
@@ -988,6 +1195,8 @@ export async function startMemoryService(config: Config): Promise<void> {
`[MemoryService] ${skillsBefore.size} existing skill(s) in memory`,
);
const inboxCandidatesBefore = await snapshotInboxCandidates(memoryDir);
// Read existing skills for context (memory-extracted + global/workspace)
const existingSkillsSummary = await buildExistingSkillsSummary(
skillsDir,
@@ -999,11 +1208,23 @@ export async function startMemoryService(config: Config): Promise<void> {
);
}
// Surface the current inbox state to the agent so it can rewrite
// existing canonical patches in place instead of accumulating new ones
// across sessions.
const pendingInboxSummary = await buildPendingInboxSummary(memoryDir);
if (pendingInboxSummary) {
debugLogger.log(
`[MemoryService] Pending inbox surfaced to agent:\n${pendingInboxSummary}`,
);
}
// Build agent definition and context
const agentDefinition = SkillExtractionAgent(
skillsDir,
sessionIndex,
existingSkillsSummary,
memoryDir,
pendingInboxSummary,
);
const context = buildAgentLoopContext(config);
@@ -1109,6 +1330,18 @@ export async function startMemoryService(config: Config): Promise<void> {
);
}
// Anything still in .inbox/ is reviewable; nothing is auto-applied.
const memoryFilesUpdated: string[] = [];
const memoryCandidatesCreated = prefixRelativePaths(
'.inbox',
getChangedSnapshotPaths(
diffFileSnapshots(
inboxCandidatesBefore,
await snapshotInboxCandidates(memoryDir),
),
),
);
const processedSessions = candidateSessions
.filter((session) =>
processedSessionKeys.has(getSessionVersionKey(session)),
@@ -1127,6 +1360,8 @@ export async function startMemoryService(config: Config): Promise<void> {
lastUpdated: session.lastUpdated,
})),
processedSessions,
memoryCandidatesCreated,
memoryFilesUpdated,
skillsCreated,
turnCount: normalizeOptionalNumber(executorResult?.turn_count),
durationMs: normalizeOptionalNumber(executorResult?.duration_ms),
@@ -1139,8 +1374,17 @@ export async function startMemoryService(config: Config): Promise<void> {
};
await writeExtractionState(statePath, updatedState);
if (skillsCreated.length > 0 || patchesCreatedThisRun.length > 0) {
if (
skillsCreated.length > 0 ||
patchesCreatedThisRun.length > 0 ||
memoryCandidatesCreated.length > 0
) {
const completionParts: string[] = [];
if (memoryCandidatesCreated.length > 0) {
completionParts.push(
`prepared ${memoryCandidatesCreated.length} memory candidate(s): ${memoryCandidatesCreated.join(', ')}`,
);
}
if (skillsCreated.length > 0) {
completionParts.push(
`created ${skillsCreated.length} skill(s): ${skillsCreated.join(', ')}`,
@@ -1155,6 +1399,11 @@ export async function startMemoryService(config: Config): Promise<void> {
`[MemoryService] Completed in ${elapsed}s. ${completionParts.join('; ')} (read ${processedSessions.length}/${candidateSessions.length} surfaced session(s))`,
);
const feedbackParts: string[] = [];
if (memoryCandidatesCreated.length > 0) {
feedbackParts.push(
`${memoryCandidatesCreated.length} memory candidate${memoryCandidatesCreated.length > 1 ? 's' : ''} extracted from past sessions`,
);
}
if (skillsCreated.length > 0) {
feedbackParts.push(
`${skillsCreated.length} new skill${skillsCreated.length > 1 ? 's' : ''} extracted from past sessions: ${skillsCreated.join(', ')}`,
+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(
+17 -5
View File
@@ -45,6 +45,7 @@ import type { ResourceRegistry } from '../resources/resource-registry.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
import { coreEvents } from '../utils/events.js';
import type { EnvironmentSanitizationConfig } from '../services/environmentSanitization.js';
@@ -105,9 +106,11 @@ describe('mcp-client', () => {
workspaceContext = new WorkspaceContext(testWorkspace);
});
afterEach(() => {
vi.restoreAllMocks();
afterEach(async () => {
vi.useRealTimers();
await cleanupTmpDir(testWorkspace);
workspaceContext = null as unknown as WorkspaceContext;
vi.restoreAllMocks();
});
describe('McpClient', () => {
@@ -2410,7 +2413,10 @@ describe('connectToMcpServer with OAuth', () => {
vi.mocked(MCPOAuthProvider).mockReturnValue(mockAuthProvider);
});
afterEach(() => {
afterEach(async () => {
vi.useRealTimers();
await cleanupTmpDir(testWorkspace);
workspaceContext = null as unknown as WorkspaceContext;
vi.clearAllMocks();
});
@@ -2617,7 +2623,10 @@ describe('connectToMcpServer - HTTP→SSE fallback', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
afterEach(async () => {
vi.useRealTimers();
await cleanupTmpDir(testWorkspace);
workspaceContext = null as unknown as WorkspaceContext;
vi.clearAllMocks();
});
@@ -2780,7 +2789,10 @@ describe('connectToMcpServer - OAuth with transport fallback', () => {
});
});
afterEach(() => {
afterEach(async () => {
vi.useRealTimers();
await cleanupTmpDir(testWorkspace);
workspaceContext = null as unknown as WorkspaceContext;
vi.clearAllMocks();
vi.unstubAllGlobals();
});
@@ -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)).`,
};
+1
View File
@@ -58,6 +58,7 @@ const RETRYABLE_NETWORK_CODES = [
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
'ERR_STREAM_PREMATURE_CLOSE',
];
// Node.js builds SSL error codes by prepending ERR_SSL_ to the uppercased
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-devtools",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"license": "Apache-2.0",
"type": "module",
"main": "dist/src/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-sdk",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.42.0-preview.2",
"description": "Gemini CLI SDK",
"license": "Apache-2.0",
"repository": {
+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 };
/**

Some files were not shown because too many files have changed in this diff Show More