Compare commits

...

61 Commits

Author SHA1 Message Date
gemini-cli-robot 809de1fbbf docs(changelog): update for v0.45.3 2026-06-09 22:20:12 +00:00
David Pierce 4523560278 Add documentation and migration commands for Antigravity CLI (#27765)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-06-09 21:00:26 +00:00
David Pierce f08b4af654 Vertex ai model mapping fix (#27749) 2026-06-09 20:02:50 +00:00
luisfelipe-alt 8e99c26dd8 fix(core): implement atomic update in MCP tool discovery (#27619)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-06-09 18:51:02 +00:00
Gaurav 0567b25a26 chore: remove experimental text from browser agent docs (#27746) 2026-06-08 16:23:21 +00:00
David Pierce f40498db64 update the max amount of times the Antigravity transition banner can be displayed. (#27676) 2026-06-05 14:35:13 +00:00
gemini-cli-robot 4196596f7f Changelog for v0.45.0 (#27642)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-06-03 17:15:26 +00:00
Krish Garg dceb2ea306 fix(policy): add EBUSY fallback and TOML parse recovery (#19919) (#21541)
Signed-off-by: krishdef7 <gargkrish06@gmail.com>
Co-authored-by: Sikandar <ma5161310@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-06-03 10:12:50 -07:00
David Pierce e4315b36eb Respect backend definitions for 3.5 flash and Update auto mode to use 3.5 flash when the flag is enabled. (#27645) 2026-06-03 15:12:53 +00:00
gemini-cli-robot d2cd12a7cb Changelog for v0.46.0-preview.0 (#27641)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-06-03 03:43:45 +00:00
gemini-cli-robot ae87e208ac chore(release): bump version to 0.47.0-nightly.20260602.gcfcecebe8 (#27644) 2026-06-03 03:43:10 +00:00
Sri Pasumarthi cfcecebe80 fix(ci): use pull_request_target trigger to grant write access on fork PRs (#27637) 2026-06-02 19:42:47 +00:00
Sri Pasumarthi 5110bdf56c chore(ci): add optimized PR size labeler and batch workflows (#27616) 2026-06-02 02:05:33 +00:00
David Pierce 665228e983 Transition to flash GA model when experiment flag is present. (#27570) 2026-06-01 23:36:49 +00:00
Tommaso Sciortino 013914071c Adding quote (#27571) 2026-05-29 13:43:16 -07:00
nirali 211e7d1aec fix(cli): prevent spam loop when preferredEditor is invalid (#25324)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-29 13:43:03 -07:00
gemini-cli-robot b77beba13a Changelog for v0.44.0 (#27569)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-29 19:40:17 +00:00
gemini-cli-robot c82e2b5976 Changelog for v0.45.0-preview.0 (#27495)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-28 17:44:19 +00:00
Tommaso Sciortino bd53951dc8 fix(core): harden PTY resize against native crashes (#27496) 2026-05-28 09:16:24 -07:00
Mukunda Rao Katta 5cac7c10fa fix(cli): ignore unmapped vim normal keys (#27102) 2026-05-27 17:03:00 +00:00
Om Patel 41c9260cae fix(core): prevent blacklist bypass in mcp list (#27377)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-05-26 22:08:37 +00:00
Tommaso Sciortino 8b56d27901 fix(core): suppress PTY resize EBADF errors (#27461) 2026-05-26 19:43:51 +00:00
Daniel Weis 85563dabe8 fix(core): bypass routing classifiers to prevent orphaned function response errors (#27389) 2026-05-26 16:19:41 +00:00
David Pierce 4a5d5cfc9f Update default auto routing (#27071) 2026-05-26 14:31:07 +00:00
Ramón Medrano Llamas 630ecc21b9 fix(cli): filter internal session context from history during resumption (#27391) 2026-05-24 06:38:53 +00:00
Billy Biggs 3cc7e5b096 Revert "fix(core): prevent SIGHUP kills in PTY environments" (#27401) 2026-05-23 20:52:45 +00:00
gemini-cli-robot a00d03efc6 Changelog for v0.43.0 (#27361)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-22 22:26:32 +00:00
gemini-cli-robot 980a2574df Changelog for v0.44.0-preview.0 (#27360)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-22 22:26:24 +00:00
jvargassanchez-dot 5188601de0 fix(core): force update_topic tool to execute sequentially (#27357) 2026-05-22 20:39:27 +00:00
joshualitt e6f92d66f6 feat(context): Complete simplification work. (#27345) 2026-05-22 18:06:40 +00:00
jvargassanchez-dot d1fa323cfb Feat/a2a expose usage metadata (#27288) 2026-05-22 17:51:39 +00:00
Syed Ayman Quadri ba04e99bea fix(cli): prevent Termux relaunch and resize remount loops (#27110)
Co-authored-by: Spencer <spencertang@google.com>
2026-05-22 06:33:52 +00:00
gemini-cli-robot 6afb109953 chore(release): bump version to 0.45.0-nightly.20260521.g854f811be (#27362) 2026-05-22 01:35:39 +00:00
Coco Sheng 854f811be0 perf: optimize issue triage and lifecycle management (#27346) 2026-05-21 18:42:59 +00:00
Dev Randalpura c4d0e3ca36 fix(ui): added volta to auto update check (#27353) 2026-05-21 18:05:33 +00:00
gemini-cli-robot df1d1119c3 chore/release: bump version to 0.44.0-nightly.20260521.g57c42a5c4 (#27324) 2026-05-21 17:52:43 +00:00
joshualitt 50c2e6764f fix(context): Ensure last message is processed. (#27232) 2026-05-21 15:46:26 +00:00
Coco Sheng 906f8a3151 ci: robust stale issue lifecycle and consolidated triage labels (#27015) 2026-05-20 21:50:19 +00:00
adithya32 64cb88d50e fix(windows): resolve interactive shell arrow-key navigation on Windows (#23505) 2026-05-20 21:41:34 +00:00
tison 96903d50a1 Auto detect pnpm global installation path for macOS and Windows (#22748)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Coco Sheng <cocosheng@google.com>
2026-05-20 21:26:11 +00:00
Coco Sheng 5c4420cc27 fix(core): dynamic fallback routing for exhausted quota models (#27315) 2026-05-20 20:59:33 +00:00
PROTHAM f79d5e059c fix(core): prevent SIGHUP kills in PTY environments (WSL2/Kitty/Alacritty) (#27267) 2026-05-20 20:43:14 +00:00
Coco Sheng d7384c446f fix(core): centralize path validation to prevent crashes from malformed prompts (#27211) 2026-05-20 19:55:57 +00:00
Dev Randalpura e440e02866 fix(core): Added date field for the GCal MCP (#27251) 2026-05-20 18:31:52 +00:00
Hashaam Zahid c854b60f75 fix(scripts): scrub CI env vars in dev to keep interactive mode (#27159) 2026-05-20 17:55:54 +00:00
Coco Sheng 99ae4d8b81 fix(core): generalize MCP compliance fix for tool results (#27045) 2026-05-20 17:31:00 +00:00
Debasish aebdba6565 fix(core): improve Alpine shell compatibility (#26770) 2026-05-20 17:16:22 +00:00
Yuvraj Angad Singh 26f8c0f65e refactor: decouple stored session deletion from ChatRecordingService (#22920) (#27039) 2026-05-20 17:09:17 +00:00
Coco Sheng 29481a1562 fix: robust ripgrep path resolution and 1p hermetic execution support (#27253) 2026-05-20 16:29:00 +00:00
Coco Sheng 2c85f57402 fix(cli): integrate PolicyEngine into ACP session to prevent deadlocks (#23507) (#27252) 2026-05-20 16:16:09 +00:00
Ramón Medrano Llamas 124539b5cc fix(devtools): bundle devtools package to avoid resolution errors (#27250) 2026-05-20 07:14:57 +00:00
gemini-cli-robot ec4910f0bb Changelog for v0.43.0-preview.1 (#27297)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-20 03:49:29 +00:00
Andrea Alberti 57c42a5c40 feat(cli): add Sublime Text and Emacs Client editors, improve error messages and documentation (#21090)
Co-authored-by: Ananth Kini <ananthkini1@gmail.com>
2026-05-19 20:03:25 +00:00
ashishch432 8997488ea6 fix(cli): preserve proxy-agent named exports in ESM bundle (#27145) 2026-05-19 19:41:31 +00:00
kaluchi 6589cdf11b Proposal: deterministic encoding for child-process I/O (#27247) 2026-05-19 19:41:04 +00:00
Dev Randalpura 9de4289287 fix(core): correctly handle nullable array types in MCP tools (#27228) 2026-05-19 18:24:18 +00:00
Coco Sheng 37f3a4c90a fix(core): respect NO_PROXY in global fetch dispatcher (#27216) 2026-05-19 17:56:59 +00:00
Om Patel fcc8c62b8b fix(core): prevent path traversal in custome command file injection (#27234) 2026-05-19 17:37:46 +00:00
Adam Weidman c4758ba820 feat(core): wire AgentSession invocations into agent-tool (#26948) 2026-05-19 16:56:43 +00:00
Ramón Medrano Llamas 96a8d1d069 fix(cli): bundle ink worker-entry.js (#27249) 2026-05-19 16:47:36 +00:00
Dev Randalpura 3494fda2cf fix(core): add exception handling to migrateFromFileStorage (#27229) 2026-05-19 15:10:21 +00:00
211 changed files with 10905 additions and 4356 deletions
+188 -154
View File
@@ -5,176 +5,218 @@
*/
module.exports = async ({ github, context, core }) => {
const rawLabels = process.env.LABELS_OUTPUT;
core.info(`Raw labels JSON: ${rawLabels}`);
let parsedLabels;
try {
// First, try to parse the raw output as JSON.
parsedLabels = JSON.parse(rawLabels);
} catch (jsonError) {
// If that fails, check for a markdown code block.
core.warning(
`Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`,
);
const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
parsedLabels = JSON.parse(jsonMatch[1].trim());
} catch (markdownError) {
core.setFailed(
`Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawLabels}`,
);
return;
const extractJson = (raw) => {
if (!raw || raw === '[]' || raw === '') return [];
try {
// First, try to parse the raw output as JSON.
return JSON.parse(raw);
} catch {
// If that fails, check for a markdown code block.
core.info(
'Direct JSON parsing failed. Trying to extract from a markdown block.',
);
const jsonMatch = raw.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
return JSON.parse(jsonMatch[1].trim());
} catch (markdownError) {
core.warning(
`Failed to parse extracted JSON from markdown block: ${markdownError.message}`,
);
}
}
} else {
// If no markdown block, try to find a raw JSON array in the output.
// The CLI may include debug/log lines (e.g. telemetry init, YOLO mode)
// before the actual JSON response.
const jsonArrayMatch = rawLabels.match(
// Try to find a raw JSON array in the output.
const jsonArrayMatch = raw.match(
/\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/,
);
if (jsonArrayMatch) {
try {
parsedLabels = JSON.parse(jsonArrayMatch[0]);
} catch (extractError) {
// It's possible the regex matched from a `[STARTUP]` log all the way to the end
// of the JSON array. We need to be more aggressive and find the FIRST `[ { "issue_number"`
core.warning(
`Strict array match failed: ${extractError.message}. Attempting to clean leading noisy brackets.`,
);
const fallbackMatch = rawLabels.match(
/(\[\s*\{\s*"issue_number"[\s\S]*)/,
);
return JSON.parse(jsonArrayMatch[0]);
} catch {
const fallbackMatch = raw.match(/(\[\s*\{\s*"issue_number"[\s\S]*)/);
if (fallbackMatch) {
try {
// We might have grabbed trailing noise too, so we find the last closing bracket
const cleaned = fallbackMatch[0].substring(
0,
fallbackMatch[0].lastIndexOf(']') + 1,
);
parsedLabels = JSON.parse(cleaned);
return JSON.parse(cleaned);
} catch (fallbackError) {
core.setFailed(
`Found JSON-like content but failed to parse: ${fallbackError.message}\nRaw output: ${rawLabels}`,
core.warning(
`Failed to parse extracted JSON using fallback regex: ${fallbackError.message}`,
);
return;
}
} else {
core.setFailed(
`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawLabels}`,
);
return;
}
}
} else {
core.setFailed(
`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawLabels}`,
);
return;
}
}
}
core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`);
core.warning('No valid JSON could be extracted from input.');
return [];
};
for (const entry of parsedLabels) {
const issueNumber = entry.issue_number;
if (!issueNumber) {
core.info(
`Skipping entry with no issue number: ${JSON.stringify(entry)}`,
);
continue;
// Collect all outputs from environment variables
// Prioritize EFFORT results over STANDARD results by processing Effort FIRST
// so that its labels appear first in the merged arrays (and thus win in mutually exclusive logic)
const effortRaw = process.env.LABELS_OUTPUT_EFFORT;
const standardRaw = process.env.LABELS_OUTPUT_STANDARD;
const genericRaw = process.env.LABELS_OUTPUT;
const resultsByIssue = new Map();
const processResults = (results, _sourceName) => {
for (const entry of results) {
const issueNumber = entry.issue_number;
if (!issueNumber) continue;
if (!resultsByIssue.has(issueNumber)) {
resultsByIssue.set(issueNumber, {
issue_number: issueNumber,
labels_to_add: [...(entry.labels_to_add || [])],
labels_to_remove: [...(entry.labels_to_remove || [])],
explanation: entry.explanation || '',
effort_analysis: entry.effort_analysis || '',
});
} else {
const existing = resultsByIssue.get(issueNumber);
// Combine labels
existing.labels_to_add = [
...new Set([
...existing.labels_to_add,
...(entry.labels_to_add || []),
]),
];
existing.labels_to_remove = [
...new Set([
...existing.labels_to_remove,
...(entry.labels_to_remove || []),
]),
];
// Combine explanations (if different)
if (
entry.explanation &&
!existing.explanation.includes(entry.explanation)
) {
existing.explanation = existing.explanation
? `${existing.explanation}\n\n${entry.explanation}`
: entry.explanation;
}
// Take effort analysis if present
if (entry.effort_analysis && !existing.effort_analysis) {
existing.effort_analysis = entry.effort_analysis;
}
}
}
};
// Order matters: Effort first so its labels win in conflict resolution
processResults(extractJson(effortRaw), 'EFFORT');
processResults(extractJson(standardRaw), 'STANDARD');
processResults(extractJson(genericRaw), 'GENERIC');
const finalResults = Array.from(resultsByIssue.values());
core.info(`Aggregated triage results for ${finalResults.length} issues.`);
for (const entry of finalResults) {
const issueNumber = entry.issue_number;
let labelsToAdd = entry.labels_to_add || [];
let labelsToRemove = entry.labels_to_remove || [];
let existingLabels = [];
labelsToRemove.push('status/need-triage');
if (labelsToAdd.includes('status/manual-triage')) {
// If the AI flagged it for manual triage, remove bot-triaged if it exists
labelsToRemove.push('status/bot-triaged');
// Ensure we don't accidentally try to add bot-triaged if the AI returned it
labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged');
} else {
// Standard successful bot triage
labelsToAdd.push('status/bot-triaged');
}
// Deduplicate arrays
labelsToAdd = [...new Set(labelsToAdd)];
labelsToRemove = [...new Set(labelsToRemove)];
// Fetch existing labels to auto-resolve conflicts
// Fetch existing labels early
try {
const { data: issueData } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const existingLabels = issueData.labels.map((l) =>
existingLabels = issueData.labels.map((l) =>
typeof l === 'string' ? l : l.name,
);
const hasNewArea = labelsToAdd.some((l) => l.startsWith('area/'));
if (hasNewArea) {
const existingAreas = existingLabels.filter((l) =>
l.startsWith('area/'),
);
labelsToRemove.push(...existingAreas);
}
const hasNewPriority = labelsToAdd.some((l) => l.startsWith('priority/'));
if (hasNewPriority) {
const existingPriorities = existingLabels.filter((l) =>
l.startsWith('priority/'),
);
labelsToRemove.push(...existingPriorities);
}
const hasNewKind = labelsToAdd.some((l) => l.startsWith('kind/'));
if (hasNewKind) {
const existingKinds = existingLabels.filter((l) =>
l.startsWith('kind/'),
);
labelsToRemove.push(...existingKinds);
}
// Re-deduplicate and filter out labels we are trying to add
labelsToRemove = [...new Set(labelsToRemove)].filter(
(l) => !labelsToAdd.includes(l),
);
} catch (e) {
core.warning(
`Failed to fetch existing labels for #${issueNumber}: ${e.message}`,
);
}
// Enforce mutually exclusive area labels
const areaLabelsToAdd = labelsToAdd.filter((l) => l.startsWith('area/'));
if (areaLabelsToAdd.length > 1) {
core.warning(
`Issue #${issueNumber} has multiple area labels to add: ${areaLabelsToAdd.join(', ')}. Keeping only the first one.`,
// Programmatic Priority Downgrade Logic
if (labelsToAdd.includes('status/need-information')) {
const targetPriority = labelsToAdd.find((l) => l.startsWith('priority/'));
if (targetPriority) {
let downgradedPriority = null;
if (targetPriority === 'priority/p0')
downgradedPriority = 'priority/p1';
if (targetPriority === 'priority/p1')
downgradedPriority = 'priority/p2';
if (downgradedPriority) {
core.info(
`Programmatically downgrading ${targetPriority} to ${downgradedPriority} due to status/need-information`,
);
labelsToAdd = labelsToAdd.filter((l) => l !== targetPriority);
labelsToAdd.push(downgradedPriority);
}
}
}
labelsToRemove.push('status/need-triage');
if (
labelsToAdd.includes('status/manual-triage') ||
existingLabels.includes('status/manual-triage')
) {
labelsToRemove.push('status/bot-triaged');
labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged');
} else {
labelsToAdd.push('status/bot-triaged');
}
// Resolve internal conflicts (e.g., adding P1 and P2)
// We already resolved these by putting Effort first in the combined list
// Resolve external conflicts with existing labels
if (labelsToAdd.some((l) => l.startsWith('area/'))) {
labelsToRemove.push(
...existingLabels.filter((l) => l.startsWith('area/')),
);
const firstArea = areaLabelsToAdd[0];
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith('area/') || l === firstArea,
}
if (labelsToAdd.some((l) => l.startsWith('priority/'))) {
labelsToRemove.push(
...existingLabels.filter((l) => l.startsWith('priority/')),
);
}
if (labelsToAdd.some((l) => l.startsWith('kind/'))) {
labelsToRemove.push(
...existingLabels.filter((l) => l.startsWith('kind/')),
);
}
// Enforce mutually exclusive priority labels
const priorityLabelsToAdd = labelsToAdd.filter((l) =>
l.startsWith('priority/'),
// Enforce mutual exclusivity in the TO-ADD list (Architect wins)
const exclusivePrefixes = ['area/', 'priority/', 'kind/'];
for (const prefix of exclusivePrefixes) {
const filtered = labelsToAdd.filter((l) => l.startsWith(prefix));
if (filtered.length > 1) {
const winner = filtered[0]; // First one wins
core.info(
`Issue #${issueNumber} has multiple ${prefix} labels suggested. Keeping "${winner}" and discarding others.`,
);
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith(prefix) || l === winner,
);
}
}
// Final deduplication and cleanup
labelsToRemove = [...new Set(labelsToRemove)].filter(
(l) => !labelsToAdd.includes(l) && existingLabels.includes(l),
);
labelsToAdd = [...new Set(labelsToAdd)].filter(
(l) => !existingLabels.includes(l),
);
if (priorityLabelsToAdd.length > 1) {
core.warning(
`Issue #${issueNumber} has multiple priority labels to add: ${priorityLabelsToAdd.join(', ')}. Keeping only the first one.`,
);
const firstPriority = priorityLabelsToAdd[0];
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith('priority/') || l === firstPriority,
);
}
// Batch label operations
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -182,10 +224,8 @@ module.exports = async ({ github, context, core }) => {
issue_number: issueNumber,
labels: labelsToAdd,
});
const explanation = entry.explanation ? ` - ${entry.explanation}` : '';
core.info(
`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`,
`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`,
);
}
@@ -199,11 +239,10 @@ module.exports = async ({ github, context, core }) => {
name: label,
});
} catch (e) {
if (e.status !== 404) {
if (e.status !== 404)
core.warning(
`Failed to remove label ${label} from #${issueNumber}: ${e.message}`,
);
}
}
}
core.info(
@@ -211,34 +250,29 @@ module.exports = async ({ github, context, core }) => {
);
}
if (
(entry.explanation && process.env.SUPPRESS_COMMENT !== 'true') ||
entry.effort_analysis
) {
// Post comment if needed
const needsInfoAdded =
labelsToAdd.includes('status/need-information') &&
!existingLabels.includes('status/need-information');
const hasEffortAnalysis = !!entry.effort_analysis;
if (needsInfoAdded || hasEffortAnalysis) {
let commentBody = '';
if (entry.explanation && process.env.SUPPRESS_COMMENT !== 'true') {
commentBody += entry.explanation;
}
if (entry.effort_analysis) {
if (needsInfoAdded && entry.explanation) commentBody += entry.explanation;
if (hasEffortAnalysis) {
if (commentBody) commentBody += '\n\n';
commentBody += `**Effort Analysis:**\n${entry.effort_analysis}`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: commentBody,
});
}
if (
(!entry.labels_to_add || entry.labels_to_add.length === 0) &&
(!entry.labels_to_remove || entry.labels_to_remove.length === 0)
) {
core.info(
`No labels to add or remove for #${issueNumber}, leaving as is`,
);
if (commentBody) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: commentBody,
});
core.info(`Posted required comment for #${issueNumber}`);
}
}
}
};
+36 -12
View File
@@ -22,29 +22,53 @@ module.exports = async ({ github, context, core }) => {
for (const issue of issuesToCleanup) {
try {
await github.rest.issues.removeLabel({
const { data: issueData } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/need-triage',
});
core.info(
`Successfully removed status/need-triage from #${issue.number}`,
const labels = issueData.labels.map((l) =>
typeof l === 'string' ? l : l.name,
);
} catch (error) {
if (error.status === 404) {
if (
labels.includes('status/bot-triaged') &&
labels.includes('status/need-triage')
) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/need-triage',
});
core.info(
`Label status/need-triage not found on #${issue.number}, skipping.`,
);
} else {
core.warning(
`Failed to remove label from #${issue.number}: ${error.message}`,
`Successfully removed status/need-triage from #${issue.number}`,
);
}
if (
labels.includes('status/bot-triaged') &&
labels.includes('status/manual-triage')
) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/bot-triaged',
});
core.info(
`Successfully removed status/bot-triaged from #${issue.number} because it requires manual triage`,
);
}
} catch (error) {
core.warning(
`Failed to clean up labels for #${issue.number}: ${error.message}`,
);
}
}
core.info(
`Cleaned up status/need-triage from ${issuesToCleanup.length} issues.`,
`Cleaned up conflicting labels from ${issuesToCleanup.length} issues.`,
);
};
@@ -1,60 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
module.exports = async ({ github, context, core }) => {
core.info('Fetching open issues to check for conflicting labels...');
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const conflictingLabelIssues = [];
for (const issue of issues) {
if (issue.pull_request) continue;
const areaLabels = issue.labels
.filter((l) => l.name && l.name.startsWith('area/'))
.map((l) => l.name);
const priorityLabels = issue.labels
.filter((l) => l.name && l.name.startsWith('priority/'))
.map((l) => l.name);
if (areaLabels.length > 1 || priorityLabels.length > 1) {
let message = `Issue #${issue.number} has conflicting labels:`;
if (areaLabels.length > 1)
message += ` multiple areas (${areaLabels.join(', ')}).`;
if (priorityLabels.length > 1)
message += ` multiple priorities (${priorityLabels.join(', ')}).`;
core.info(message);
conflictingLabelIssues.push({
number: issue.number,
title: issue.title,
body: issue.body || '',
});
}
}
// Limit to 50 to avoid overwhelming the AI in a single run
const issuesToProcess = conflictingLabelIssues.slice(0, 50);
fs.writeFileSync(
'conflicting_labels_issues.json',
JSON.stringify(issuesToProcess, null, 2),
);
core.info(
`Found ${conflictingLabelIssues.length} issues with conflicting labels. Wrote ${issuesToProcess.length} to conflicting_labels_issues.json`,
);
};
+142 -30
View File
@@ -16,6 +16,8 @@ module.exports = async ({ github, context, core }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;
core.info(`Running in ${dryRun ? 'DRY RUN' : 'PRODUCTION'} mode.`);
const STALE_LABEL = 'stale';
const NEED_INFO_LABEL = 'status/need-information';
const EXEMPT_LABELS = [
@@ -79,14 +81,16 @@ module.exports = async ({ github, context, core }) => {
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).`);
let items = await github.paginate(
github.rest.search.issuesAndPullRequests,
{
q: query,
per_page: 100,
sort: 'updated',
order: 'asc',
},
);
core.info(`Found ${items.length} items.`);
for (const item of items) {
try {
await callback(item);
@@ -114,16 +118,21 @@ module.exports = async ({ github, context, core }) => {
per_page: 5,
});
// Check if the last comment is from a non-maintainer
// Check if the last comment is from a non-maintainer and not a bot
const lastComment = comments[0];
if (
lastComment &&
lastComment.user?.type !== 'Bot' &&
!(await isMaintainer(lastComment.user, lastComment.author_association))
) {
core.info(
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would remove ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
} else {
core.info(
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
await github.rest.issues
.removeLabel({
owner,
@@ -141,10 +150,14 @@ module.exports = async ({ github, context, core }) => {
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) {
if (dryRun) {
core.info(
`[DRY RUN] Would close #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
);
} else {
core.info(
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
);
await github.rest.issues.createComment({
owner,
repo,
@@ -156,6 +169,7 @@ module.exports = async ({ github, context, core }) => {
repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
},
@@ -163,11 +177,21 @@ module.exports = async ({ github, context, core }) => {
// 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) {
const isBug = item.labels.some((l) =>
(typeof l === 'string' ? l : l.name).toLowerCase().includes('bug'),
);
const bodyText = isBug
? `This bug report has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. Many issues are resolved in newer releases. Please verify if the issue persists in the latest Gemini CLI version. If it does, please leave a comment to keep this open. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`
: `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!`;
if (dryRun) {
core.info(`[DRY RUN] Would mark #${item.number} as stale.`);
} else {
core.info(`Marking #${item.number} as stale.`);
await github.rest.issues.addLabels({
owner,
repo,
@@ -178,18 +202,97 @@ module.exports = async ({ github, context, core }) => {
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!`,
body: bodyText,
});
}
},
);
// 3. Handle Stale Close (14 days with stale label)
// 3. Handle Stale Removal & Close
await processItems(
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`,
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery}`,
async (item) => {
core.info(`Closing stale item #${item.number}.`);
if (!dryRun) {
// Fetch full timeline to see events and comments
const timeline = await github.paginate(
github.rest.issues.listEventsForTimeline,
{
owner,
repo,
issue_number: item.number,
per_page: 100,
},
);
// Find exactly when the Stale label was added
// We look for the last 'labeled' event for STALE_LABEL
const staleEventIndex = timeline.findLastIndex(
(e) =>
e.event === 'labeled' &&
e.label?.name?.toLowerCase() === STALE_LABEL.toLowerCase(),
);
if (staleEventIndex === -1) return; // Fallback if no event found
const staleEvent = timeline[staleEventIndex];
const eventsAfterStale = timeline.slice(staleEventIndex + 1);
// Check for meaningful activity after the Stale label was applied
const meaningfulEvents = eventsAfterStale.filter((e) => {
const actor = e.actor?.login || '';
const isBot =
actor.includes('[bot]') || actor.includes('github-actions');
if (isBot) return false;
// Explicit whitelist of meaningful events for humans
if (
[
'commented',
'cross-referenced',
'connected',
'reopened',
'assigned',
].includes(e.event)
) {
return true;
}
return false;
});
if (meaningfulEvents.length > 0) {
// Activity detected, remove Stale label
if (dryRun) {
core.info(
`[DRY RUN] Would remove ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`,
);
} else {
core.info(
`Removing ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`,
);
await github.rest.issues
.removeLabel({
owner,
repo,
issue_number: item.number,
name: STALE_LABEL,
})
.catch(() => {});
}
return;
}
// No meaningful activity. Check if 14 days have passed.
const labeledDate = new Date(staleEvent.created_at);
if (labeledDate > closeThreshold) {
// Has not been 14 days since it was ACTUALLY marked stale
return;
}
if (dryRun) {
core.info(`[DRY RUN] Would close stale item #${item.number}.`);
} else {
core.info(`Closing stale item #${item.number}.`);
await github.rest.issues.createComment({
owner,
repo,
@@ -201,6 +304,7 @@ module.exports = async ({ github, context, core }) => {
repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
},
@@ -222,8 +326,12 @@ module.exports = async ({ github, context, core }) => {
async (pr) => {
if (await isMaintainer(pr.user, pr.author_association)) return;
core.info(`Nudging PR #${pr.number} for contribution policy.`);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would nudge PR #${pr.number} for contribution policy.`,
);
} else {
core.info(`Nudging PR #${pr.number} for contribution policy.`);
await github.rest.issues.addLabels({
owner,
repo,
@@ -246,10 +354,14 @@ module.exports = async ({ github, context, core }) => {
async (pr) => {
if (await isMaintainer(pr.user, pr.author_association)) return;
core.info(
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would close PR #${pr.number} per contribution policy (no 'help wanted').`,
);
} else {
core.info(
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
);
await github.rest.issues.createComment({
owner,
repo,
@@ -68,6 +68,7 @@ jobs:
ISSUE_NUMBER: '${{ github.event.issue.number }}'
REPOSITORY: '${{ github.repository }}'
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -335,14 +335,41 @@ jobs:
return;
}
const newAreaLabel = labelsToAdd[0];
// Get current labels to resolve conflicts
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const currentLabelNames = currentLabels.map(l => l.name);
const currentAreaLabels = currentLabelNames.filter(name => name.startsWith('area/'));
const labelsToRemove = currentAreaLabels.filter(name => name !== newAreaLabel);
for (const label of labelsToRemove) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: label
});
core.info(`Removed conflicting area label: ${label}`);
} catch (e) {
core.warning(`Failed to remove label ${label}: ${e.message}`);
}
}
// Set labels based on triage result
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd
labels: [newAreaLabel]
});
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`);
core.info(`Successfully added labels for #${issueNumber}: ${newAreaLabel}`);
- name: 'Post Issue Analysis Failure Comment'
if: |-
@@ -29,6 +29,18 @@ jobs:
with:
persist-credentials: false
- name: 'Install Utilities'
run: |
sudo apt-get update
sudo apt-get install -y ripgrep
- name: 'Get Current Version'
id: 'get_version'
run: |
VERSION=$(jq -r .version package.json | cut -d'-' -f1)
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
echo "🚀 Current CLI Version: ${VERSION}"
- name: 'Generate GitHub App Token'
id: 'generate_token'
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
@@ -62,12 +74,20 @@ jobs:
- name: 'Find Issues with Conflicting Labels'
if: |-
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const findConflictingLabels = require('./.github/scripts/find-conflicting-labels.cjs');
await findConflictingLabels({ github, context, core });
env:
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |-
set -euo pipefail
echo '🔍 Fetching open issues to find conflicts...'
# Fetch up to 2000 open issues in one quick GraphQL-backed query
gh issue list --repo "${GITHUB_REPOSITORY}" --search "is:issue is:open" --limit 2000 --json number,title,body,labels > all_open_issues.json
echo '🧹 Filtering issues with multiple area/ or priority/ labels...'
jq -c '[ .[] | select( (.labels | map(select(.name | startswith("area/"))) | length) > 1 or (.labels | map(select(.name | startswith("priority/"))) | length) > 1 ) ] | .[0:50]' all_open_issues.json > conflicting_labels_issues.json
CONFLICT_COUNT=$(jq 'length' conflicting_labels_issues.json)
echo "Found ${CONFLICT_COUNT} issues with conflicting labels (capped at 50 for processing)."
- name: 'Find untriaged issues'
if: |-
@@ -81,19 +101,19 @@ jobs:
echo '🔍 Finding issues missing area labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--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 50 --json number,title,body > no_area_issues.json
--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 50 --json number,title,body,labels > no_area_issues.json
echo '🔍 Finding issues missing kind labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body > no_kind_issues.json
--search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body,labels > no_kind_issues.json
echo '🏷️ Finding issues missing priority labels...'
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 50 --json number,title,body > no_priority_issues.json
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body,labels > no_priority_issues.json
echo '📏 Finding issues missing effort labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 5 --json number,title,body > no_effort_issues.json
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 20 --json number,title,body,labels > no_effort_issues.json
echo '🔄 Merging and deduplicating standard triage issues...'
if [ ! -f conflicting_labels_issues.json ]; then echo "[]" > conflicting_labels_issues.json; fi
@@ -155,9 +175,11 @@ jobs:
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
REPOSITORY: '${{ github.repository }}'
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
GEMINI_EXP: 'gemini_exp.json'
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
GEMINI_MODEL: 'gemini-3-flash-preview'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -174,7 +196,7 @@ jobs:
"read_file"
],
"telemetry": {
"enabled": true,
"enabled": false,
"target": "gcp"
}
}
@@ -188,12 +210,12 @@ jobs:
## Steps
1. You are only able to use the echo and read_file commands. Review the available labels in the environment variable: "${AVAILABLE_LABELS}".
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage.
3. Review the issue title, body and any comments provided in the JSON file.
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage (including their current labels).
3. Review the issue title, body, current labels, and any comments provided in the JSON file.
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, and priority/*.
5. Label Policy:
- If the issue already has a kind/ label, do not change it.
- If the issue has exactly ONE priority/ label, do not change it.
- If the issue has exactly ONE priority/ label, do not change it (unless you are explicitly re-evaluating an ambiguous priority).
- If the issue is missing a priority/ label, OR if the issue currently has MULTIPLE priority/ labels, you must evaluate the issue's impact to determine exactly ONE priority level (priority/p0, priority/p1, priority/p2, priority/p3, or priority/unknown) based the guidelines. If you are fixing an issue with multiple priority/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
- If the issue has exactly ONE area/ label, do not change it.
- If the issue is missing an area/ label, OR if the issue currently has MULTIPLE area/ labels, select exactly ONE area/ label that best fits the issue. Issues MUST NOT have multiple area/ labels. If you are fixing an issue with multiple area/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
@@ -213,11 +235,12 @@ jobs:
]
```
If an issue cannot be classified, do not include it in the output array.
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
9. For each issue, carefully check if the CLI version is present. It is usually found under the "### Client information" header, as a bullet point (e.g., "• CLI Version: 0.33.1", "* **CLI Version:** 0.42.0"), or in the output of the `/about` command.
- **Only for issues classified as kind/bug:** If the version is provided but is more than 6 minor versions older than the most recent release (current version is ${{ steps.get_version.outputs.version }}), apply the status/need-information label and leave a comment politely asking the user to verify if the issue persists in the latest version.
10. **Only for issues classified as kind/bug:** If the issue does not have sufficient information, recommend the status/need-information label and leave a comment politely requesting the missing details. For example, if repro steps are missing, ask for them; if the CLI version is completely missing, ask for the version information in the explanation section below. Do not ask for version info if it is already in the issue body. (Check both bullet points and bold text). For features and enhancements, the CLI version is NOT required.
11. If you think an issue is a Priority/P0, you MUST apply the priority/p1 label AND the status/manual-triage label, and include a note in your explanation that it likely requires P0 escalation.
12. If the issue is highly ambiguous, completely lacks a description, or you are torn between two lower priorities (like P2 vs P3), you MUST retain the existing priority label if one is already present. Do not toggle the priority if you do not have enough information to make a definitive change.
13. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
## Guidelines
@@ -230,12 +253,14 @@ jobs:
- Identify exactly ONE area/ label. Do NOT assign multiple area/ labels to a single issue.
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
- Identify exactly ONE priority/ label. Do NOT assign multiple priority/ labels to a single issue.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
- **Do not manually downgrade the priority.** Always assign the true priority based on the guidelines. The system will handle downgrades programmatically if information is missing.
- **NEVER mention label names, label removals, or label additions in your `explanation`.** The explanation must be purely written for the user (e.g., "Please provide your CLI version.") without exposing internal triage mechanics (e.g., do NOT say "Removing area/unknown to leave only area/core").
Categorization Guidelines (Priority):
P0 - Urgent Blocking Issues:
- DO NOT APPLY THE priority/p0 LABEL AUTOMATICALLY. Instead apply priority/p1 and status/manual-triage.
- Definition: Critical failures breaking core functionality for a large portion of users. Examples: CLI fails to launch globally, core commands (gemini run) crash on valid input, unhandled promise rejections on boot, critical security vulnerability.
- Note: You must apply status/manual-triage instead of priority/p0.
- Note: You must apply priority/p1 and status/manual-triage instead of priority/p0.
P1 - Critical but Workable:
- Definition: Severe issues without a reasonable workaround, significantly degrading the developer experience but not globally blocking. Examples: Specific tools failing consistently (e.g., `web_search` returns 500s), persistent PTY streaming hangs, memory leaks leading to OOM after short use.
P2 - Significant Issues:
@@ -272,9 +297,11 @@ jobs:
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
REPOSITORY: '${{ github.repository }}'
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
GEMINI_EXP: 'gemini_exp.json'
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
GEMINI_MODEL: 'gemini-3-flash-preview'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -285,7 +312,7 @@ jobs:
use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}'
settings: |-
{
"maxSessionTurns": 25,
"maxSessionTurns": 30,
"coreTools": [
"run_shell_command(echo)",
"grep_search",
@@ -293,7 +320,7 @@ jobs:
"read_file"
],
"telemetry": {
"enabled": true,
"enabled": false,
"target": "gcp"
}
}
@@ -379,30 +406,14 @@ jobs:
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
- When users report that they dont expect the model to change those would be categorized as feature requests.
- name: 'Apply Standard Labels to Issues'
- name: 'Apply Triaged Labels'
if: |-
${{ steps.gemini_standard_issue_analysis.outcome == 'success' &&
steps.gemini_standard_issue_analysis.outputs.summary != '[]' &&
steps.gemini_standard_issue_analysis.outputs.summary != '' }}
always() &&
( (steps.gemini_standard_issue_analysis.outcome == 'success' && steps.gemini_standard_issue_analysis.outputs.summary != '[]' && steps.gemini_standard_issue_analysis.outputs.summary != '') ||
(steps.gemini_effort_issue_analysis.outcome == 'success' && steps.gemini_effort_issue_analysis.outputs.summary != '[]' && steps.gemini_effort_issue_analysis.outputs.summary != '') )
env:
REPOSITORY: '${{ github.repository }}'
LABELS_OUTPUT: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
SUPPRESS_COMMENT: 'true'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const applyLabels = require('./.github/scripts/apply-issue-labels.cjs');
await applyLabels({ github, context, core });
- name: 'Apply Effort Labels to Issues'
if: |-
${{ steps.gemini_effort_issue_analysis.outcome == 'success' &&
steps.gemini_effort_issue_analysis.outputs.summary != '[]' &&
steps.gemini_effort_issue_analysis.outputs.summary != '' }}
env:
REPOSITORY: '${{ github.repository }}'
LABELS_OUTPUT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
LABELS_OUTPUT_STANDARD: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
LABELS_OUTPUT_EFFORT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
@@ -428,9 +439,12 @@ jobs:
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |-
set -euo pipefail
echo '🧹 Finding issues that have both bot-triaged and need-triage labels...'
echo '🧹 Finding issues that have conflicting status labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > issues_to_cleanup.json
--search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > cleanup_1.json
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue label:status/bot-triaged label:status/manual-triage' --limit 50 --json number > cleanup_2.json
jq -c -s 'add | unique_by(.number)' cleanup_1.json cleanup_2.json > issues_to_cleanup.json
- name: 'Clean Up Triage Labels'
if: |-
@@ -0,0 +1,107 @@
name: 'PR Size Labeler (Batch)'
on:
workflow_dispatch:
inputs:
process_all:
description: 'Process all PRs (open and closed) or open only'
required: true
default: 'false'
type: 'choice'
options:
- 'true'
- 'false'
limit:
description: 'Max number of PRs to fetch and check'
required: true
default: '100'
type: 'string'
permissions:
pull-requests: 'write'
jobs:
batch-label:
runs-on: 'ubuntu-latest'
steps:
- name: 'Batch label PRs'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GH_REPO: '${{ github.repository }}'
run: |
# Determine the state filter
STATE="open"
if [ "${{ github.event.inputs.process_all }}" = "true" ]; then
STATE="all"
fi
LIMIT="${{ github.event.inputs.limit }}"
echo "Batch labeling up to $LIMIT $STATE PRs..."
# 1. Ensure standard premium size labels exist in the repository (self-healing)
gh label create "size/XS" --color "7ee081" --description "XS: <10 lines changed" 2>/dev/null || true
gh label create "size/S" --color "a6d49f" --description "S: 10-49 lines changed" 2>/dev/null || true
gh label create "size/M" --color "f7d070" --description "M: 50-249 lines changed" 2>/dev/null || true
gh label create "size/L" --color "f48c06" --description "L: 250-999 lines changed" 2>/dev/null || true
gh label create "size/XL" --color "dc2f02" --description "XL: >=1000 lines changed" 2>/dev/null || true
# 2. Query PR list with all required fields in ONE call to prevent N+1 queries
PR_LIST=$(gh pr list --state "$STATE" --limit "$LIMIT" --json number,additions,deletions,labels)
if [ -z "$PR_LIST" ] || [ "$PR_LIST" = "[]" ]; then
echo "️ No PRs found matching the criteria."
exit 0
fi
# Parse and iterate over PRs
UPDATED_COUNT=0
SKIPPED_COUNT=0
echo "$PR_LIST" | jq -c '.[]' | while read -r PR_JSON; do
PR_NUMBER=$(echo "$PR_JSON" | jq '.number')
ADDITIONS=$(echo "$PR_JSON" | jq '.additions')
DELETIONS=$(echo "$PR_JSON" | jq '.deletions')
TOTAL=$((ADDITIONS + DELETIONS))
# Calculate target size
if [ $TOTAL -lt 10 ]; then
SIZE="size/XS"
elif [ $TOTAL -lt 50 ]; then
SIZE="size/S"
elif [ $TOTAL -lt 250 ]; then
SIZE="size/M"
elif [ $TOTAL -lt 1000 ]; then
SIZE="size/L"
else
SIZE="size/XL"
fi
# Inspect existing labels to detect discrepancies
EXISTING_LABELS=$(echo "$PR_JSON" | jq -r '.labels[].name' 2>/dev/null || echo "")
LABELS_TO_REMOVE=()
for L in size/XS size/S size/M size/L size/XL; do
if echo "$EXISTING_LABELS" | grep -Fq "$L" && [ "$L" != "$SIZE" ]; then
LABELS_TO_REMOVE+=("--remove-label" "$L")
fi
done
LABEL_TO_ADD=()
if ! echo "$EXISTING_LABELS" | grep -Fq "$SIZE"; then
LABEL_TO_ADD+=("--add-label" "$SIZE")
fi
# Update labels if there's a difference
if [ ${#LABELS_TO_REMOVE[@]} -gt 0 ] || [ ${#LABEL_TO_ADD[@]} -gt 0 ]; then
echo "🔄 PR #$PR_NUMBER (+$ADDITIONS/-$DELETIONS = $TOTAL lines): updating size to $SIZE"
gh pr edit "$PR_NUMBER" "${LABELS_TO_REMOVE[@]}" "${LABEL_TO_ADD[@]}" 2>/dev/null || true
UPDATED_COUNT=$((UPDATED_COUNT + 1))
else
echo "✅ PR #$PR_NUMBER (+$ADDITIONS/-$DELETIONS = $TOTAL lines): already has correct label ($SIZE). Skipping."
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
fi
done
echo "============================================"
echo "🎉 Batch run completed!"
echo "Skipped (already correct): $SKIPPED_COUNT"
echo "Updated: $UPDATED_COUNT"
+120
View File
@@ -0,0 +1,120 @@
name: 'PR Size Labeler'
on:
pull_request_target:
types: ['opened', 'synchronize', 'reopened']
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to label manually (for workflow_dispatch)'
required: false
type: 'string'
permissions:
pull-requests: 'write'
issues: 'write'
jobs:
size-label:
runs-on: 'ubuntu-latest'
steps:
- name: 'Run size labeler'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GH_REPO: '${{ github.repository }}'
run: |
# Determine the target PR number
if [ -n "${{ github.event.pull_request.number }}" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
elif [ -n "${{ github.event.inputs.pr_number }}" ]; then
PR_NUMBER="${{ github.event.inputs.pr_number }}"
else
echo "❌ Error: No PR number provided."
exit 1
fi
echo "Checking PR #$PR_NUMBER..."
# 1. Ensure standard premium size labels exist in the repository (self-healing)
# size/XS: Light green (#7ee081)
# size/S: Yellow-green (#a6d49f)
# size/M: Amber/Yellow (#f7d070)
# size/L: Orange (#f48c06)
# size/XL: Red (#dc2f02)
gh label create "size/XS" --color "7ee081" --description "XS: <10 lines changed" 2>/dev/null || true
gh label create "size/S" --color "a6d49f" --description "S: 10-49 lines changed" 2>/dev/null || true
gh label create "size/M" --color "f7d070" --description "M: 50-249 lines changed" 2>/dev/null || true
gh label create "size/L" --color "f48c06" --description "L: 250-999 lines changed" 2>/dev/null || true
gh label create "size/XL" --color "dc2f02" --description "XL: >=1000 lines changed" 2>/dev/null || true
# 2. Fetch PR details in a single efficient API call
PR_DATA=$(gh pr view "$PR_NUMBER" --json additions,deletions,changedFiles,labels)
if [ -z "$PR_DATA" ]; then
echo "❌ Error: Could not fetch PR details."
exit 1
fi
ADDITIONS=$(echo "$PR_DATA" | jq '.additions')
DELETIONS=$(echo "$PR_DATA" | jq '.deletions')
CHANGED_FILES=$(echo "$PR_DATA" | jq '.changedFiles')
TOTAL=$((ADDITIONS + DELETIONS))
echo "PR additions: $ADDITIONS, deletions: $DELETIONS, total changes: $TOTAL, files: $CHANGED_FILES"
# 3. Calculate new size label
if [ $TOTAL -lt 10 ]; then
SIZE="size/XS"
elif [ $TOTAL -lt 50 ]; then
SIZE="size/S"
elif [ $TOTAL -lt 250 ]; then
SIZE="size/M"
elif [ $TOTAL -lt 1000 ]; then
SIZE="size/L"
else
SIZE="size/XL"
fi
# 4. Check existing labels and update only if necessary
EXISTING_LABELS=$(echo "$PR_DATA" | jq -r '.labels[].name' 2>/dev/null || echo "")
LABELS_TO_REMOVE=()
for L in size/XS size/S size/M size/L size/XL; do
if echo "$EXISTING_LABELS" | grep -Fq "$L" && [ "$L" != "$SIZE" ]; then
LABELS_TO_REMOVE+=("--remove-label" "$L")
fi
done
LABEL_TO_ADD=()
if ! echo "$EXISTING_LABELS" | grep -Fq "$SIZE"; then
LABEL_TO_ADD+=("--add-label" "$SIZE")
fi
# Perform a single, highly atomic edit call if changes are needed
if [ ${#LABELS_TO_REMOVE[@]} -gt 0 ] || [ ${#LABEL_TO_ADD[@]} -gt 0 ]; then
echo "Updating labels: removing ${LABELS_TO_REMOVE[*]}, adding $SIZE"
gh pr edit "$PR_NUMBER" "${LABELS_TO_REMOVE[@]}" "${LABEL_TO_ADD[@]}"
else
echo "✅ PR #$PR_NUMBER already has the correct size label ($SIZE)."
fi
# 5. Premium, anti-spam comment logic (updates previous comment to keep thread clean)
COMMENT="📊 PR Size: **$SIZE**
- Lines changed: **$TOTAL**
- Additions: +$ADDITIONS
- Deletions: -$DELETIONS
- Files changed: $CHANGED_FILES"
# Find any existing size labeler comment by the github-actions bot
echo "Searching for existing size comment..."
COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \
--jq '.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("📊 PR Size:"))) | .id' | head -n 1)
if [ -n "$COMMENT_ID" ]; then
echo "Updating existing comment (ID: $COMMENT_ID)..."
gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -X PATCH -f body="$COMMENT" > /dev/null
else
echo "Creating new comment..."
gh pr comment "$PR_NUMBER" --body "$COMMENT" > /dev/null
fi
echo "🎉 PR size labeling completed successfully."
+1
View File
@@ -23,3 +23,4 @@ Thumbs.db
**/SKILL.md
packages/sdk/test-data/*.json
*.mdx
packages/vscode-ide-companion/NOTICES.txt
+47
View File
@@ -18,6 +18,53 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.45.0 - 2026-06-03
- **Context Simplification:** Completed major architectural work to simplify the
`ContextManager`, improving system robustness and performance
([#27345](https://github.com/google-gemini/gemini-cli/pull/27345) by
@joshualitt).
- **A2A Usage Metadata:** Exposed critical usage metadata in the Agent-to-Agent
(A2A) protocol for better resource tracking
([#27288](https://github.com/google-gemini/gemini-cli/pull/27288) by
@jvargassanchez-dot).
- **Reliability Fixes:** Addressed Termux relaunch loops, PTY resize errors, and
forced sequential execution for topic updates
([#27110](https://github.com/google-gemini/gemini-cli/pull/27110) by @saymanq,
[#27357](https://github.com/google-gemini/gemini-cli/pull/27357) by
@jvargassanchez-dot,
[#27461](https://github.com/google-gemini/gemini-cli/pull/27461) by
@scidomino).
## Announcements: v0.44.0 - 2026-05-27
- **Unified Auto Mode:** Streamlined the automation experience by merging
specialized Auto modes into a single, unified mode
([#26714](https://github.com/google-gemini/gemini-cli/pull/26714) by
@DavidAPierce).
- **New Editor Integrations:** Added native support for Sublime Text and Emacs
Client ([#21090](https://github.com/google-gemini/gemini-cli/pull/21090) by
@alberti42).
- **Enhanced TUI Testing:** Introduced `agent-tui` and `tui-tester` skills for
programmatic testing and automation of terminal UI applications
([#27121](https://github.com/google-gemini/gemini-cli/pull/27121) by
@adamfweidman).
## Announcements: v0.43.0 - 2026-05-22
- **Surgical Code Edits:** Steered Gemini models to prefer the `edit` tool for
surgical modifications, improving speed and precision
([#26480](https://github.com/google-gemini/gemini-cli/pull/26480) by
@aishaneeshah).
- **Session Export and Import:** Added the ability to export sessions to files
and import them via a new flag, facilitating session portability
([#26514](https://github.com/google-gemini/gemini-cli/pull/26514) by
@cocosheng-g).
- **Adaptive Token Estimation:** Introduced an adaptive token calculator for
more accurate content size estimation, enhancing context management efficiency
([#26888](https://github.com/google-gemini/gemini-cli/pull/26888) by
@joshualitt).
## Announcements: v0.42.0 - 2026-05-12
- **Auto Memory Inbox:** Introduced a new inbox flow for Auto Memory with a
+50 -264
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.42.0
# Latest stable release: v0.45.3
Released: May 12, 2026
Released: June 09, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,272 +11,58 @@ npm install -g @google/gemini-cli
## Highlights
- **Auto Memory Inbox:** Introduced a new inbox flow for Auto Memory using a
canonical-patch contract, enabling more robust and manageable skill
extraction.
- **Gemma 4 Default:** Gemma 4 models are now enabled by default via the Gemini
API, providing improved performance and capabilities out of the box.
- **Voice Mode Polish:** Added wave animations for visual feedback and
privacy/compliance UX warnings specifically for the Gemini Live backend.
- **Session Management:** Added a `--delete` flag to the `/exit` command for
instant session deletion and introduced `/bug-memory` for easier heap
diagnostics.
- **Improved Reliability:** Reduced default API timeouts to 60s and implemented
retries for undici and premature stream closure errors.
- **Context Manager Simplification:** Completed a significant refactoring of the
context management system to improve reliability and architectural clarity.
- **A2A Usage Metadata:** Enhanced the Agent-to-Agent protocol to expose usage
metadata, enabling more transparent resource monitoring.
- **Terminal & PTY Robustness:** Resolved several critical issues related to
terminal interactions, including Termux relaunch loops and PTY resize errors.
- **Routing Optimizations:** Updated default auto-routing and bypassed
classifiers for specific tool responses to prevent orphaned function errors.
- **Tool Execution Control:** Forced the `update_topic` tool to execute
sequentially, ensuring consistent narrative flow in agent interactions.
## What's Changed
- fix(cli): prevent automatic updates from switching to less stable channels by
@Adib234 in [#26132](https://github.com/google-gemini/gemini-cli/pull/26132)
- chore(release): bump version to 0.42.0-nightly.20260428.g59b2dea0e by
- fix(patch): cherry-pick f08b4af to release/v0.45.2-pr-27749 to patch version
v0.45.2 and create version 0.45.3 by @gemini-cli-robot in
[#27769](https://github.com/google-gemini/gemini-cli/pull/27769)
- chore(release): bump version to 0.45.0-nightly.20260521.g854f811be by
@gemini-cli-robot in
[#26142](https://github.com/google-gemini/gemini-cli/pull/26142)
- fix(cli): pass node arguments via NODE_OPTIONS during relaunch to support SEA
by @cocosheng-g in
[#26130](https://github.com/google-gemini/gemini-cli/pull/26130)
- fix(cli): handle DECKPAM keypad Enter sequences in terminal by @Gitanaskhan26
in [#26092](https://github.com/google-gemini/gemini-cli/pull/26092)
- docs(cli): point plan-mode session retention to actual /settings labels by
@ifitisit in [#25978](https://github.com/google-gemini/gemini-cli/pull/25978)
- fix(core): add missing oauth fields support in subagent parsing by
@abhipatel12 in
[#26141](https://github.com/google-gemini/gemini-cli/pull/26141)
- fix(core): disconnect extension-backed MCP clients in stopExtension by
@cocosheng-g in
[#26136](https://github.com/google-gemini/gemini-cli/pull/26136)
- Update documentation workflows with workspace trust by @g-samroberts in
[#26150](https://github.com/google-gemini/gemini-cli/pull/26150)
- refactor(acp): modularize monolithic acpClient into specialized files by
@sripasg in [#26143](https://github.com/google-gemini/gemini-cli/pull/26143)
- test: fix failures due to antigravity environment leakage by @adamfweidman in
[#26162](https://github.com/google-gemini/gemini-cli/pull/26162)
- fix(core): add explicit empty log guard in A2A pushMessage by @adamfweidman in
[#26198](https://github.com/google-gemini/gemini-cli/pull/26198)
- feat(cli): add --delete flag to /exit command for session deletion by
@AbdulTawabJuly in
[#19332](https://github.com/google-gemini/gemini-cli/pull/19332)
- test(core): add regression test for issue for ToolConfirmationResponse by
@Adib234 in [#26194](https://github.com/google-gemini/gemini-cli/pull/26194)
- Add the ability to @ mention the gemini robot. by @gundermanc in
[#26207](https://github.com/google-gemini/gemini-cli/pull/26207)
- test(evals): add EvalMetadata JSDoc annotations to older tests by @akh64bit in
[#26147](https://github.com/google-gemini/gemini-cli/pull/26147)
- fix(core): reduce default API timeout to 60s and enable retries for undici
timeouts by @Adib234 in
[#26191](https://github.com/google-gemini/gemini-cli/pull/26191)
- fix(core): distinguish fallback chains and fix maxAttempts for auto vs
explicit model selection by @adamfweidman in
[#26163](https://github.com/google-gemini/gemini-cli/pull/26163)
- fix(cli): handle InvalidStream event gracefully without throwing by
@adamfweidman in
[#26218](https://github.com/google-gemini/gemini-cli/pull/26218)
- ci(github-actions): switch to github app token and fix bot self-trigger by
@gundermanc in
[#26223](https://github.com/google-gemini/gemini-cli/pull/26223)
- Respect logPrompts flag for logging sensitive fields by @lp-peg in
[#26153](https://github.com/google-gemini/gemini-cli/pull/26153)
- fix: correct API key validation logic in handleApiKeySubmit by
@martin-hsu-test in
[#25453](https://github.com/google-gemini/gemini-cli/pull/25453)
- fix(agent): prevent exit_plan_mode from being called via shell by
@Abhijit-2592 in
[#26230](https://github.com/google-gemini/gemini-cli/pull/26230)
- # Fix: Inconsistent Case-Sensitivity in GrepTool by @.github/workflows/gemini-cli-bot-pulse.yml[bot] in [#26235](https://github.com/google-gemini/gemini-cli/pull/26235)
- docs(core): add automated gemma setup guide by @Samee24 in
[#26233](https://github.com/google-gemini/gemini-cli/pull/26233)
- Allow non-https proxy urls to support container environments by @stevemk14ebr
in [#26234](https://github.com/google-gemini/gemini-cli/pull/26234)
- fix(bot): productivity and backlog optimizations by @gundermanc in
[#26236](https://github.com/google-gemini/gemini-cli/pull/26236)
- refactor(acp): delegate prompt turn processing logic to GeminiClient by
@sripasg in [#26222](https://github.com/google-gemini/gemini-cli/pull/26222)
- fix(cli): refine platform-specific undo/redo and smart bubbling for WSL by
@cocosheng-g in
[#26202](https://github.com/google-gemini/gemini-cli/pull/26202)
- fix: suppress duplicate extension warnings during startup by @cocosheng-g in
[#26208](https://github.com/google-gemini/gemini-cli/pull/26208)
- fix(cli): use byte length instead of string length for readStdin size limits
by @Adib234 in
[#26224](https://github.com/google-gemini/gemini-cli/pull/26224)
- fix(ui): made shell tool header wrap on Ctrl+O by @devr0306 in
[#26229](https://github.com/google-gemini/gemini-cli/pull/26229)
- Changelog for v0.41.0-preview.0 by @gemini-cli-robot in
[#26244](https://github.com/google-gemini/gemini-cli/pull/26244)
- Skip binary CLI relaunch by @ruomengz in
[#26261](https://github.com/google-gemini/gemini-cli/pull/26261)
- fix(cli): do not override GOOGLE_CLOUD_PROJECT in Cloud Shell when using
Vertex AI by @jackwotherspoon in
[#24455](https://github.com/google-gemini/gemini-cli/pull/24455)
- docs(cli): add skill discovery troubleshooting checklist to tutorial by
@pmenic in [#26018](https://github.com/google-gemini/gemini-cli/pull/26018)
- docs(policy-engine): link to tools reference for tool names and args by
@Aaxhirrr in [#22081](https://github.com/google-gemini/gemini-cli/pull/22081)
- Fix posting invalid response to a comment by @gundermanc in
[#26266](https://github.com/google-gemini/gemini-cli/pull/26266)
- fix(cli): prevent informational logs from polluting json output by
@cocosheng-g in
[#26264](https://github.com/google-gemini/gemini-cli/pull/26264)
- feat(ui): added microphone and updated placeholder for voice mode by @devr0306
in [#26270](https://github.com/google-gemini/gemini-cli/pull/26270)
- feat(cli): Add 'list' subcommand to '/commands' by @Jwhyee in
[#22324](https://github.com/google-gemini/gemini-cli/pull/22324)
- fix(core): ensure tool output cleanup on session deletion for legacy files by
@cocosheng-g in
[#26263](https://github.com/google-gemini/gemini-cli/pull/26263)
- Docs: Update Agent Skills documentation by @jkcinouye in
[#22388](https://github.com/google-gemini/gemini-cli/pull/22388)
- test(acp): add missing coverage for extensions command error paths by
@sahilkirad in
[#25313](https://github.com/google-gemini/gemini-cli/pull/25313)
- Changelog for v0.40.0 by @gemini-cli-robot in
[#26245](https://github.com/google-gemini/gemini-cli/pull/26245)
- fix: report AgentExecutionBlocked in non-interactive programmatic modes by
@cocosheng-g in
[#26262](https://github.com/google-gemini/gemini-cli/pull/26262)
- feat(extensions): add 'delete' as an alias for /extensions uninstall by
@martin-hsu-test in
[#25660](https://github.com/google-gemini/gemini-cli/pull/25660)
- fix(core): silently skip GEMINI.md paths that are directories (EISDIR) by
@martin-hsu-test in
[#25662](https://github.com/google-gemini/gemini-cli/pull/25662)
- fix(ci): checkout PR branch instead of main in bot workflow by @gundermanc in
[#26289](https://github.com/google-gemini/gemini-cli/pull/26289)
- fix(cli): use resolved sandbox state for auto-update check by @Adib234 in
[#26285](https://github.com/google-gemini/gemini-cli/pull/26285)
- # Metrics Integrity & Standardized Reporting (BT-01) by @.github/workflows/gemini-cli-bot-pulse.yml[bot] in [#26240](https://github.com/google-gemini/gemini-cli/pull/26240)
- Add Star History section to README by @bdmorgan in
[#26290](https://github.com/google-gemini/gemini-cli/pull/26290)
- Add Star History section to README by @bdmorgan in
[#26308](https://github.com/google-gemini/gemini-cli/pull/26308)
- Remove Star History section from README by @bdmorgan in
[#26309](https://github.com/google-gemini/gemini-cli/pull/26309)
- test(evals): add behavioral eval for file creation and write_file tool
selection by @akh64bit in
[#26292](https://github.com/google-gemini/gemini-cli/pull/26292)
- feat(config): enable Gemma 4 models by default via Gemini API by @Abhijit-2592
in [#26307](https://github.com/google-gemini/gemini-cli/pull/26307)
- fix(cli): insert voice transcription at cursor position instead of ap… by
@Zheyuan-Lin in
[#26287](https://github.com/google-gemini/gemini-cli/pull/26287)
- fix(ui): fix issue with box edges by @gundermanc in
[#26148](https://github.com/google-gemini/gemini-cli/pull/26148)
- fix(cli): respect .env override for GOOGLE_CLOUD_PROJECT by @DavidAPierce in
[#26288](https://github.com/google-gemini/gemini-cli/pull/26288)
- fix(ci): robust version checking in release verification by @scidomino in
[#26337](https://github.com/google-gemini/gemini-cli/pull/26337)
- fix(cli): enable daemon relaunch in binary and bundle keytar by @ruomengz in
[#26333](https://github.com/google-gemini/gemini-cli/pull/26333)
- fix(core): discourage unprompted git add . in prompt snippets by @akh64bit in
[#26220](https://github.com/google-gemini/gemini-cli/pull/26220)
- feat(ui): added wave animation for voice mode by @devr0306 in
[#26284](https://github.com/google-gemini/gemini-cli/pull/26284)
- fix(cli): prevent Escape from clearing input buffer (#17083) by @cocosheng-g
in [#26339](https://github.com/google-gemini/gemini-cli/pull/26339)
- fix(cli): undeprecate --prompt and correct positional query docs by @Adib234
in [#26329](https://github.com/google-gemini/gemini-cli/pull/26329)
- Metrics updates by @.github/workflows/gemini-cli-bot-pulse.yml[bot] in
[#26348](https://github.com/google-gemini/gemini-cli/pull/26348)
- fix(core): remove "System: Please continue." injection on InvalidStream events
by @SandyTao520 in
[#26340](https://github.com/google-gemini/gemini-cli/pull/26340)
- docs(policy-engine): add tool argument keys reference and shell policy
cross-links by @harshpujari in
[#25292](https://github.com/google-gemini/gemini-cli/pull/25292)
- fix(cli): resolve Ghostty/raw-mode False Cancellation in oauth flow by
@Aarchi-07 in [#25026](https://github.com/google-gemini/gemini-cli/pull/25026)
- fix(core): reset session-scoped state on resumption by @cocosheng-g in
[#26342](https://github.com/google-gemini/gemini-cli/pull/26342)
- Fix bulk of remaining issues with generalist profile by @joshualitt in
[#26073](https://github.com/google-gemini/gemini-cli/pull/26073)
- fix(core): make subagents aware of active approval modes by @akh64bit in
[#23608](https://github.com/google-gemini/gemini-cli/pull/23608)
- fix(acp): resolve agent mode disconnect and improve mode awareness by @sripasg
in [#26332](https://github.com/google-gemini/gemini-cli/pull/26332)
- docs(sdk): add JSDoc to exported interfaces in packages/sdk/src/types.ts by
@cocosheng-g in
[#26441](https://github.com/google-gemini/gemini-cli/pull/26441)
- perf: skip redundant GEMINI.md loading in partialConfig by @cocosheng-g in
[#26443](https://github.com/google-gemini/gemini-cli/pull/26443)
- Enhance React guidelines by @psinha40898 in
[#22667](https://github.com/google-gemini/gemini-cli/pull/22667)
- feat(core): reinforce Inquiry constraints to prevent unauthorized changes by
@akh64bit in [#26310](https://github.com/google-gemini/gemini-cli/pull/26310)
- revert: fix(ci): robust version checking in release verification (#26337) by
@scidomino in [#26450](https://github.com/google-gemini/gemini-cli/pull/26450)
- refactor(UI): created constants file for ThemeDialog by @devr0306 in
[#26446](https://github.com/google-gemini/gemini-cli/pull/26446)
- docs: fix GitHub capitalization in releases guide by @haosenwang1018 in
[#26379](https://github.com/google-gemini/gemini-cli/pull/26379)
- fix(cli): ensure branch indicator updates in sub-directories and worktrees by
@Adib234 in [#26330](https://github.com/google-gemini/gemini-cli/pull/26330)
- feat: add minimal V8 heap snapshot utility for memory diagnostics by
@cocosheng-g in
[#26440](https://github.com/google-gemini/gemini-cli/pull/26440)
- fix(hooks): preserve non-text parts in fromHookLLMRequest by @SandyTao520 in
[#26275](https://github.com/google-gemini/gemini-cli/pull/26275)
- fix(cli): allow early stdout when config is undefined by @cocosheng-g in
[#26453](https://github.com/google-gemini/gemini-cli/pull/26453)
- fix(cli)#21297: clear skills consent dialog before reload by @manavmax in
[#26431](https://github.com/google-gemini/gemini-cli/pull/26431)
- fix(cli): render LaTeX-style output as Unicode in the TUI by @dimssu in
[#25802](https://github.com/google-gemini/gemini-cli/pull/25802)
- fix(core): use close event instead of exit in child_process fallback by
@tusaryan in [#25695](https://github.com/google-gemini/gemini-cli/pull/25695)
- feat(voice): add privacy and compliance UX warning for Gemini Live backend by
@cocosheng-g in
[#26454](https://github.com/google-gemini/gemini-cli/pull/26454)
- feat(memory): add Auto Memory inbox flow with canonical-patch contract by
@SandyTao520 in
[#26338](https://github.com/google-gemini/gemini-cli/pull/26338)
- test(cleanup): fix temporary directory leaks in test suites by @Adib234 in
[#26217](https://github.com/google-gemini/gemini-cli/pull/26217)
- feat: add ignoreLocalEnv setting and --ignore-env flag (#2493) by @cocosheng-g
in [#26445](https://github.com/google-gemini/gemini-cli/pull/26445)
- docs(sdk): add JSDoc to all exported interfaces and types by @fauzan171 in
[#26277](https://github.com/google-gemini/gemini-cli/pull/26277)
- feat(cli): improve /agents refresh logging by @cocosheng-g in
[#26442](https://github.com/google-gemini/gemini-cli/pull/26442)
- Fix: make Dockerfile self-contained with multi-stage build by @Famous077 in
[#24277](https://github.com/google-gemini/gemini-cli/pull/24277)
- fix(core): filter unsupported multimodal types from tool responses by
@aishaneeshah in
[#26352](https://github.com/google-gemini/gemini-cli/pull/26352)
- fix(core): properly format markdown in AskUser tool by unescaping newlines by
@Adib234 in [#26349](https://github.com/google-gemini/gemini-cli/pull/26349)
- feat(bot): add actions spend metric script by @gundermanc in
[#26463](https://github.com/google-gemini/gemini-cli/pull/26463)
- feat(cli): add /bug-memory command and auto-capture heap snapshot in /bug by
@Anjaligarhwal in
[#25639](https://github.com/google-gemini/gemini-cli/pull/25639)
- fix(cli): make SkillInboxDialog fit and scroll in alternate buffer by
@SandyTao520 in
[#26455](https://github.com/google-gemini/gemini-cli/pull/26455)
- Robust Scale-Safe Lifecycle Consolidation by @gemini-cli-robot in
[#26355](https://github.com/google-gemini/gemini-cli/pull/26355)
- fix(ci): respect exempt labels when closing stale items by @gundermanc in
[#26475](https://github.com/google-gemini/gemini-cli/pull/26475)
- fix(cli): use os.homedir() for home directory warning check by @TirthNaik-99
in [#25890](https://github.com/google-gemini/gemini-cli/pull/25890)
- fix(a2a-server): resolve tool approval race condition and improve status
reporting by @kschaab in
[#26479](https://github.com/google-gemini/gemini-cli/pull/26479)
- fix(cli): prevent settings dialog border clipping using maxHeight by
@jackwotherspoon in
[#26507](https://github.com/google-gemini/gemini-cli/pull/26507)
- feat: allow queuing messages during compression (#24071) by @cocosheng-g in
[#26506](https://github.com/google-gemini/gemini-cli/pull/26506)
- fix(core): retry on ERR_STREAM_PREMATURE_CLOSE errors by @cocosheng-g in
[#26519](https://github.com/google-gemini/gemini-cli/pull/26519)
- fix(core): Minor fixes for generalist profile. by @joshualitt in
[#26357](https://github.com/google-gemini/gemini-cli/pull/26357)
- 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 by
[#27362](https://github.com/google-gemini/gemini-cli/pull/27362)
- fix(cli): prevent Termux relaunch and resize remount loops by @saymanq in
[#27110](https://github.com/google-gemini/gemini-cli/pull/27110)
- Feat/a2a expose usage metadata by @jvargassanchez-dot in
[#27288](https://github.com/google-gemini/gemini-cli/pull/27288)
- feat(context): Complete simplification work. by @joshualitt in
[#27345](https://github.com/google-gemini/gemini-cli/pull/27345)
- fix(core): force update_topic tool to execute sequentially by
@jvargassanchez-dot in
[#27357](https://github.com/google-gemini/gemini-cli/pull/27357)
- Changelog for v0.44.0-preview.0 by @gemini-cli-robot in
[#27360](https://github.com/google-gemini/gemini-cli/pull/27360)
- Changelog for v0.43.0 by @gemini-cli-robot in
[#27361](https://github.com/google-gemini/gemini-cli/pull/27361)
- Revert "fix(core): prevent SIGHUP kills in PTY environments" by @bbiggs in
[#27401](https://github.com/google-gemini/gemini-cli/pull/27401)
- fix(cli): filter internal session context from history during resumption by
@rmedranollamas in
[#27391](https://github.com/google-gemini/gemini-cli/pull/27391)
- Update default auto routing by @DavidAPierce in
[#27071](https://github.com/google-gemini/gemini-cli/pull/27071)
- fix(core): bypass routing classifiers to prevent orphaned function response
errors by @danielweis in
[#27389](https://github.com/google-gemini/gemini-cli/pull/27389)
- fix(core): suppress PTY resize EBADF errors by @scidomino in
[#27461](https://github.com/google-gemini/gemini-cli/pull/27461)
- fix(core): prevent blacklist bypass in mcp list by @ompatel-aiml in
[#27377](https://github.com/google-gemini/gemini-cli/pull/27377)
- fix(cli): ignore unmapped vim normal keys by @MukundaKatta in
[#27102](https://github.com/google-gemini/gemini-cli/pull/27102)
- fix(patch): cherry-pick bd53951 to release/v0.45.0-preview.0-pr-27496 to patch
version v0.45.0-preview.0 and create version 0.45.0-preview.1 by
@gemini-cli-robot in
[#26544](https://github.com/google-gemini/gemini-cli/pull/26544)
- 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 by
@gemini-cli-robot in
[#26590](https://github.com/google-gemini/gemini-cli/pull/26590)
[#27535](https://github.com/google-gemini/gemini-cli/pull/27535)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.41.2...v0.42.0
https://github.com/google-gemini/gemini-cli/compare/v0.44.1...v0.45.3
+28 -178
View File
@@ -1,6 +1,6 @@
# Preview release: v0.43.0-preview.0
# Preview release: v0.46.0-preview.0
Released: May 12, 2026
Released: June 3, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -13,184 +13,34 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Surgical Code Edits:** Steer models to use the `edit` tool for precise code
modifications, improving accuracy and reducing context usage.
- **Session Portability:** Added ability to export chat sessions to files and
import them via a new CLI flag, enabling session persistence and sharing.
- **Enhanced Security:** Introduced comprehensive shell command safety
evaluations and strengthened model steering to prevent unauthorized changes.
- **Context Management:** Implemented a new adaptive token calculator for more
accurate content size estimations and optimized context pipelines.
- **UX Improvements:** Enhanced tool call visibility with prefixed IDs and
improved the UI for session resumption and MCP list management.
- **Model Update:** Added support for transitioning to the Flash GA model when
the experimental flag is enabled, providing access to the latest model
improvements.
- **Improved Stability:** Hardened PTY resize logic to prevent native crashes,
ensuring a more robust terminal experience.
- **Bug Fix:** Resolved an issue where an invalid `preferredEditor`
configuration could lead to a notification spam loop.
- **CI Enhancements:** Optimized Pull Request labeling and introduced batch
workflows to improve development efficiency.
## What's Changed
- feat(core): steer model to use edit tool for surgical edits, fix a typo in
[#26480](https://github.com/google-gemini/gemini-cli/pull/26480)
- docs: clarify Auto Memory proposes memory updates and skills in
[#26527](https://github.com/google-gemini/gemini-cli/pull/26527)
- fix(core): reject numeric project IDs in GOOGLE_CLOUD_PROJECT (#24695) in
[#26532](https://github.com/google-gemini/gemini-cli/pull/26532)
- fix(core): remove unsafe type assertion suppressions in error utils in
[#19881](https://github.com/google-gemini/gemini-cli/pull/19881)
- fix(core): allow redirection in YOLO and AUTO_EDIT modes without sandboxing in
[#26542](https://github.com/google-gemini/gemini-cli/pull/26542)
- ci(release): build and attach unsigned macOS binaries to releases in
[#26462](https://github.com/google-gemini/gemini-cli/pull/26462)
- fix(core): Fix chat corruption bug in context manager. in
[#26534](https://github.com/google-gemini/gemini-cli/pull/26534)
- fix(cli): provide JSON output for AgentExecutionStopped in non-interactive
mode in [#26504](https://github.com/google-gemini/gemini-cli/pull/26504)
- feat(evals): add shell command safety evals in
[#26528](https://github.com/google-gemini/gemini-cli/pull/26528)
- fix(core): handle invalid custom plans directory gracefully in
[#26560](https://github.com/google-gemini/gemini-cli/pull/26560)
- fix(acp): move tool explanation from thought stream to tool call content in
[#26554](https://github.com/google-gemini/gemini-cli/pull/26554)
- fix(a2a-server): Resolve race condition in tool completion waiting in
[#26568](https://github.com/google-gemini/gemini-cli/pull/26568)
- fix(cli): randomize sandbox container names in
[#26014](https://github.com/google-gemini/gemini-cli/pull/26014)
- fix(core): Fix hysteresis in async context management pipelines. in
[#26452](https://github.com/google-gemini/gemini-cli/pull/26452)
- Tighten private Auto Memory patch allowlist in
[#26535](https://github.com/google-gemini/gemini-cli/pull/26535)
- fix(cli): hide read-only settings scopes in
[#26249](https://github.com/google-gemini/gemini-cli/pull/26249)
- fix(ci): preserve executable bit for mac binaries in
[#26600](https://github.com/google-gemini/gemini-cli/pull/26600)
- fix(cli): improve mcp list UX in untrusted folders in
[#26457](https://github.com/google-gemini/gemini-cli/pull/26457)
- fix(core): prevent silent hang during OAuth auth on headless Linux in
[#26571](https://github.com/google-gemini/gemini-cli/pull/26571)
- Changelog for v0.42.0-preview.0 in
[#26537](https://github.com/google-gemini/gemini-cli/pull/26537)
- ci: fix Argument list too long in triage workflows in
[#26603](https://github.com/google-gemini/gemini-cli/pull/26603)
- refactor(cli): migrate core tools to native ToolDisplay property and fix UI
rendering in [#25186](https://github.com/google-gemini/gemini-cli/pull/25186)
- don't wrap args unnecessarily in
[#26599](https://github.com/google-gemini/gemini-cli/pull/26599)
- fix(core): preserve system PATH in Git environment to fix ENOENT (#25034) in
[#26587](https://github.com/google-gemini/gemini-cli/pull/26587)
- fix(routing): fix resolveClassifierModel argument mismatch in
ApprovalModeStrategy in
[#26658](https://github.com/google-gemini/gemini-cli/pull/26658)
- docs: add vi mode shortcuts and clarify MCP/custom sandbox setup in
[#23853](https://github.com/google-gemini/gemini-cli/pull/23853)
- fix(ux): fixed issue with transcribed text not showing after releasing space
in [#26609](https://github.com/google-gemini/gemini-cli/pull/26609)
- ci: fix json parsing in scheduled triage workflow in
[#26656](https://github.com/google-gemini/gemini-cli/pull/26656)
- fix(cli): hide /memory add subcommand when memoryV2 is enabled in
[#26605](https://github.com/google-gemini/gemini-cli/pull/26605)
- fix: prevent false command conflicts when launching from home directory in
[#23069](https://github.com/google-gemini/gemini-cli/pull/23069)
- fix(core): cache model routing decision in LocalAgentExecutor in
[#26548](https://github.com/google-gemini/gemini-cli/pull/26548)
- Changelog for v0.42.0-preview.2 in
[#26597](https://github.com/google-gemini/gemini-cli/pull/26597)
- skip broken test in
[#26705](https://github.com/google-gemini/gemini-cli/pull/26705)
- feat: export session to file and import via flag in
[#26514](https://github.com/google-gemini/gemini-cli/pull/26514)
- Feat: Add Machine Hostname to CLI interface in
[#25637](https://github.com/google-gemini/gemini-cli/pull/25637)
- docs(extensions): refactor releasing guide and add update mechanisms in
[#26595](https://github.com/google-gemini/gemini-cli/pull/26595)
- fix(ci): fix maintainer identification in lifecycle manager in
[#26706](https://github.com/google-gemini/gemini-cli/pull/26706)
- fix(ui): added quotes around session id in resume tip in
[#26669](https://github.com/google-gemini/gemini-cli/pull/26669)
- Changelog for v0.41.0 in
[#26670](https://github.com/google-gemini/gemini-cli/pull/26670)
- refactor(core): agent session protocol changes in
[#26661](https://github.com/google-gemini/gemini-cli/pull/26661)
- fix(context): implement loose boundary policy for gc backstop. in
[#26594](https://github.com/google-gemini/gemini-cli/pull/26594)
- fix(core): throw explicit error on dropped tool responses in
[#26668](https://github.com/google-gemini/gemini-cli/pull/26668)
- fix: resolve "function response turn must come immediately after function
call" error in
[#26691](https://github.com/google-gemini/gemini-cli/pull/26691)
- fix(core): resolve parallel tool call streaming ID collision in
[#26646](https://github.com/google-gemini/gemini-cli/pull/26646)
- feat(core): add LocalSubagentProtocol behind AgentProtocol in
[#25302](https://github.com/google-gemini/gemini-cli/pull/25302)
- fix(cli): remove noisy theme registration logs from terminal in
[#25858](https://github.com/google-gemini/gemini-cli/pull/25858)
- ci: implement codebase-aware effort level triage in
[#26666](https://github.com/google-gemini/gemini-cli/pull/26666)
- feat(acp/core): prefix tool call IDs with tool names to support tool rendering
in ACP compliant IDEs. in
[#26676](https://github.com/google-gemini/gemini-cli/pull/26676)
- fix(mcp): treat GET 404 as 405 in StreamableHTTPClientTransport in
[#24847](https://github.com/google-gemini/gemini-cli/pull/24847)
- feat(core): add RemoteSubagentProtocol behind AgentProtocol in
[#25303](https://github.com/google-gemini/gemini-cli/pull/25303)
- feat(context): Improvements to the snapshotter. in
[#26655](https://github.com/google-gemini/gemini-cli/pull/26655)
- fix(context): Change snapshotter model config. in
[#26745](https://github.com/google-gemini/gemini-cli/pull/26745)
- fix(cli): allow installing extensions from ssh repo in
[#26274](https://github.com/google-gemini/gemini-cli/pull/26274)
- fix(cli): prevent duplicate SessionStart systemMessage render in
[#25827](https://github.com/google-gemini/gemini-cli/pull/25827)
- fix(cli/acp): prevent infinite thought loop in ACP mode by disablig
nextSpeakerCheck in
[#26874](https://github.com/google-gemini/gemini-cli/pull/26874)
- fix(cli): use static tool name in confirmation prompt to avoid parsing errors
in [#26866](https://github.com/google-gemini/gemini-cli/pull/26866)
- fix(routing): Refactor tool turn handling for the conversation history in
NumericalClassifierStrategy to prevent 400 Bad Request in
[#26761](https://github.com/google-gemini/gemini-cli/pull/26761)
- fix(core): handle malformed projects.json in ProjectRegistry in
[#26885](https://github.com/google-gemini/gemini-cli/pull/26885)
- fix(ui): added a gutter width to the input prompt width calculation in
[#26882](https://github.com/google-gemini/gemini-cli/pull/26882)
- fix: prevent EISDIR crash when customIgnoreFilePaths contains directories
(#19868) in [#19898](https://github.com/google-gemini/gemini-cli/pull/19898)
- revert 6b9b778d821728427eea07b1b97ba07378137d0b in
[#26893](https://github.com/google-gemini/gemini-cli/pull/26893)
- Fix/vscode run current file ts in
[#22894](https://github.com/google-gemini/gemini-cli/pull/22894)
- Allow Enter to select session while in search mode in /resume in
[#21523](https://github.com/google-gemini/gemini-cli/pull/21523)
- fix(core): ignore .pak and .rpa game archive formats by default in
[#26884](https://github.com/google-gemini/gemini-cli/pull/26884)
- fix(cli): enable adk non-interactive session in
[#26895](https://github.com/google-gemini/gemini-cli/pull/26895)
- fix(cli): restore resume for legacy sessions in
[#26577](https://github.com/google-gemini/gemini-cli/pull/26577)
- fix: respect explicit model selection after Flash quota exhaustion (#26759) in
[#26872](https://github.com/google-gemini/gemini-cli/pull/26872)
- feat(context): Introduce adaptive token calculator to more accurately
calculate content sizes. in
[#26888](https://github.com/google-gemini/gemini-cli/pull/26888)
- chore: update checkout action configuration in workflows in
[#26897](https://github.com/google-gemini/gemini-cli/pull/26897)
- fix (telemetry): inject quota_project_id to prevent fallback to default oauth
client in [#26698](https://github.com/google-gemini/gemini-cli/pull/26698)
- Exclude extension context from skill extraction agent in
[#26879](https://github.com/google-gemini/gemini-cli/pull/26879)
- Enable NumericalRouter when using dynamic model configs in
[#26929](https://github.com/google-gemini/gemini-cli/pull/26929)
- ci: actively triage missing priority labels and intelligently clean up
conflicting labels in
[#26865](https://github.com/google-gemini/gemini-cli/pull/26865)
- refactor(core): introduce SubagentState enum for progress in
[#26934](https://github.com/google-gemini/gemini-cli/pull/26934)
- fix(ci): replace brittle --no-tag with explicit staging-tmp tag in
[#26940](https://github.com/google-gemini/gemini-cli/pull/26940)
- Incremental refactor repo agent towards skills-based composition in
[#26717](https://github.com/google-gemini/gemini-cli/pull/26717)
- fix(ui): fixed line wrap padding for selection lists in
[#26944](https://github.com/google-gemini/gemini-cli/pull/26944)
- fix(core): update read_file schema for v1 compatibility (#22183) in
[#26922](https://github.com/google-gemini/gemini-cli/pull/26922)
- fix(ci): configure git remote with token for authentication in
[#26949](https://github.com/google-gemini/gemini-cli/pull/26949)
- fix(core): harden PTY resize against native crashes by @scidomino in
[#27496](https://github.com/google-gemini/gemini-cli/pull/27496)
- Changelog for v0.45.0-preview.0 by @gemini-cli-robot in
[#27495](https://github.com/google-gemini/gemini-cli/pull/27495)
- Changelog for v0.44.0 by @gemini-cli-robot in
[#27569](https://github.com/google-gemini/gemini-cli/pull/27569)
- fix(cli): prevent spam loop when preferredEditor is invalid by @Niralisj in
[#25324](https://github.com/google-gemini/gemini-cli/pull/25324)
- Adding quote by @scidomino in
[#27571](https://github.com/google-gemini/gemini-cli/pull/27571)
- Transition to flash GA model when experiment flag is present. by @DavidAPierce
in [#27570](https://github.com/google-gemini/gemini-cli/pull/27570)
- chore(ci): add optimized PR size labeler and batch workflows by @sripasg in
[#27616](https://github.com/google-gemini/gemini-cli/pull/27616)
- fix(ci): use pull_request_target trigger to grant write access on fork PRs by
@sripasg in [#27637](https://github.com/google-gemini/gemini-cli/pull/27637)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.42.0-preview.2...v0.43.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.45.0-preview.1...v0.46.0-preview.0
+1 -5
View File
@@ -105,7 +105,7 @@ Gemini CLI comes with the following built-in subagents:
slow. You can invoke it explicitly using `@generalist`.
- **Configuration:** Enabled by default.
### Browser Agent (experimental)
### Browser Agent
- **Name:** `browser_agent`
- **Purpose:** Automate web browser tasks — navigating websites, filling forms,
@@ -115,10 +115,6 @@ Gemini CLI comes with the following built-in subagents:
the pricing table from this page," "Click the login button and enter my
credentials."
<!-- prettier-ignore -->
> [!NOTE]
> This is a preview feature currently under active development.
#### Prerequisites
The browser agent requires:
+29 -1
View File
@@ -154,7 +154,35 @@ and will never be auto-unassigned.
- **Unassign yourself** if you can no longer work on the issue by commenting
`/unassign`, so other contributors can pick it up right away.
### 6. Release automation
### 6. Automatically label PRs by size: `PR Size Labeler`
To help maintainers estimate review effort and keep the PR history clean, this
workflow automatically tags every pull request with a size label representing
the total volume of line changes.
- **Workflow File**: `.github/workflows/pr-size-labeler.yml`
- **When it runs**: Immediately after a pull request is created, synchronized
(new commits pushed), or reopened. It can also be triggered manually via
`workflow_dispatch` with a PR number.
- **What it does**:
- **Calculates total changes**: Summarizes additions and deletions across all
changed files in a single consolidated API request.
- **Applies standard size labels**:
- `size/XS`: < 10 lines changed
- `size/S`: 10-49 lines changed
- `size/M`: 50-249 lines changed
- `size/L`: 250-999 lines changed
- `size/XL`: >= 1000 lines changed
- **Updates size tag atomically**: Adds the new correct size label and removes
any obsolete size labels in one atomic step.
- **Updates/Posts PR size info comment**: Instead of spamming a new comment on
every commit push, it updates the existing size labeler status comment
inline to keep the PR conversation timeline perfectly neat and clean.
- **What you should do**:
- You do not need to take any actions. The workflow runs automatically and
updates the label and comment seamlessly as you push new updates.
### 7. Release automation
This workflow handles the process of packaging and publishing new versions of
Gemini CLI.
+107 -31
View File
@@ -105,9 +105,19 @@ their corresponding top-level category object in your `settings.json` file.
#### `general`
- **`general.preferredEditor`** (string):
- **Description:** The preferred editor to open files in.
- **`general.preferredEditor`** (enum):
- **Description:** The preferred editor to open files in. Must be one of the
built-in supported identifiers. Use /editor in the CLI to pick
interactively, or leave unset to use $VISUAL/$EDITOR.
- **Default:** `undefined`
- **Values:** `"vscode"`, `"vscodium"`, `"windsurf"`, `"cursor"`, `"zed"`,
`"antigravity"`, `"sublimetext"`, `"lapce"`, `"nova"`, `"bbedit"`, `"vim"`,
`"neovim"`, `"emacs"`, `"hx"`, `"emacsclient"`, `"micro"`
- **`general.openEditorInNewWindow`** (boolean):
- **Description:** Open VS Code-family editors in a new window when editing
files.
- **Default:** `false`
- **`general.vimMode`** (boolean):
- **Description:** Enable Vim keybindings
@@ -586,6 +596,18 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-2.5-flash-lite"
}
},
"gemini-3.1-flash-lite": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-flash-lite"
}
},
"gemini-3.5-flash": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.5-flash"
}
},
"gemma-4-31b-it": {
"extends": "chat-base-3",
"modelConfig": {
@@ -610,10 +632,16 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"gemini-3.5-flash-base": {
"extends": "base",
"modelConfig": {
"model": "gemini-3.5-flash"
}
},
"classifier": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"maxOutputTokens": 1024,
"thinkingConfig": {
@@ -625,7 +653,7 @@ their corresponding top-level category object in your `settings.json` file.
"prompt-completion": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"temperature": 0.3,
"maxOutputTokens": 16000,
@@ -638,7 +666,7 @@ their corresponding top-level category object in your `settings.json` file.
"fast-ack-helper": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"temperature": 0.2,
"maxOutputTokens": 120,
@@ -651,7 +679,7 @@ their corresponding top-level category object in your `settings.json` file.
"edit-corrector": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"thinkingConfig": {
"thinkingBudget": 0
@@ -662,7 +690,7 @@ their corresponding top-level category object in your `settings.json` file.
"summarizer-default": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
@@ -671,7 +699,7 @@ their corresponding top-level category object in your `settings.json` file.
"summarizer-shell": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
@@ -748,7 +776,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"chat-compression-3.1-flash-lite": {
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
"model": "gemini-3.1-flash-lite"
}
},
"chat-compression-2.5-pro": {
@@ -802,10 +830,10 @@ their corresponding top-level category object in your `settings.json` file.
```json
{
"gemini-3.1-flash-lite-preview": {
"gemini-3.1-flash-lite": {
"tier": "flash-lite",
"family": "gemini-3",
"isPreview": true,
"isPreview": false,
"isVisible": true,
"features": {
"thinking": false,
@@ -852,6 +880,16 @@ their corresponding top-level category object in your `settings.json` file.
"multimodalToolUse": true
}
},
"gemini-3.5-flash": {
"tier": "flash",
"family": "gemini-3",
"isPreview": false,
"isVisible": true,
"features": {
"thinking": false,
"multimodalToolUse": true
}
},
"gemini-2.5-pro": {
"tier": "pro",
"family": "gemini-2.5",
@@ -1004,9 +1042,46 @@ their corresponding top-level category object in your `settings.json` file.
"contexts": [
{
"condition": {
"hasAccessToPreview": false,
"useGemini3_5Flash": true
},
"target": "gemini-3.5-flash"
},
{
"condition": {
"hasAccessToPreview": false,
"useGemini3_5Flash": false
},
"target": "gemini-2.5-flash"
}
]
},
"gemini-3.5-flash": {
"default": "gemini-3.5-flash",
"contexts": [
{
"condition": {
"useGemini3_5Flash": false,
"hasAccessToPreview": false
},
"target": "gemini-2.5-flash"
},
{
"condition": {
"useGemini3_5Flash": false
},
"target": "gemini-3-flash-preview"
}
]
},
"gemini-2.5-flash": {
"default": "gemini-2.5-flash",
"contexts": [
{
"condition": {
"useGemini3_5Flash": true
},
"target": "gemini-3.5-flash"
}
]
},
@@ -1082,20 +1157,18 @@ their corresponding top-level category object in your `settings.json` file.
}
]
},
"gemini-3.1-flash-lite-preview": {
"default": "gemini-3.1-flash-lite-preview",
"contexts": [
{
"condition": {
"useGemini3_1FlashLite": false
},
"target": "gemini-2.5-flash-lite"
}
]
"gemini-3.1-flash-lite": {
"default": "gemini-3.1-flash-lite"
},
"flash": {
"default": "gemini-3-flash-preview",
"contexts": [
{
"condition": {
"useGemini3_5Flash": true
},
"target": "gemini-3.5-flash"
},
{
"condition": {
"hasAccessToPreview": false
@@ -1105,15 +1178,7 @@ their corresponding top-level category object in your `settings.json` file.
]
},
"flash-lite": {
"default": "gemini-2.5-flash-lite",
"contexts": [
{
"condition": {
"useGemini3_1FlashLite": true
},
"target": "gemini-3.1-flash-lite-preview"
}
]
"default": "gemini-3.1-flash-lite"
},
"auto-gemini-3": {
"default": "gemini-3-pro-preview",
@@ -1157,6 +1222,12 @@ their corresponding top-level category object in your `settings.json` file.
"flash": {
"default": "gemini-3-flash-preview",
"contexts": [
{
"condition": {
"useGemini3_5Flash": true
},
"target": "gemini-3.5-flash"
},
{
"condition": {
"hasAccessToPreview": false
@@ -1353,7 +1424,7 @@ their corresponding top-level category object in your `settings.json` file.
],
"lite": [
{
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"actions": {
"terminal": "silent",
"transient": "silent",
@@ -1964,6 +2035,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.powerUserProfile`** (boolean):
- **Description:** Less cache friendly version of the generalist profile.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.contextManagement`** (boolean):
- **Description:** Enable logic for context management.
- **Default:** `false`
+41 -2
View File
@@ -63,7 +63,6 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'@github/keytar',
'@google/gemini-cli-devtools',
];
const baseConfig = {
@@ -102,11 +101,46 @@ const cliConfig = {
plugins: createWasmPlugins(),
alias: {
'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'),
'https-proxy-agent': path.resolve(
__dirname,
'packages/cli/src/patches/https-proxy-agent.ts',
),
'http-proxy-agent': path.resolve(
__dirname,
'packages/cli/src/patches/http-proxy-agent.ts',
),
'@google/gemini-cli-devtools': path.resolve(
__dirname,
'packages/devtools/src/index.ts',
),
...commonAliases,
},
metafile: true,
};
const workerConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
},
entryPoints: {
'worker/worker-entry': path.join(
path.dirname(require.resolve('ink')),
'worker/worker-entry.js',
),
},
outdir: 'bundle',
define: {
__filename: '__chunk_filename',
__dirname: '__chunk_dirname',
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'production',
),
},
plugins: createWasmPlugins(),
alias: commonAliases,
};
const a2aServerConfig = {
...baseConfig,
banner: {
@@ -133,13 +167,18 @@ Promise.allSettled([
writeFileSync('./bundle/esbuild.json', JSON.stringify(metafile, null, 2));
}
}),
esbuild.build(workerConfig),
esbuild.build(a2aServerConfig),
]).then((results) => {
const [cliResult, a2aResult] = results;
const [cliResult, workerResult, a2aResult] = results;
if (cliResult.status === 'rejected') {
console.error('gemini.js build failed:', cliResult.reason);
process.exit(1);
}
if (workerResult.status === 'rejected') {
console.error('worker-entry.js build failed:', workerResult.reason);
process.exit(1);
}
// error in a2a-server bundling will not stop gemini.js bundling process
if (a2aResult.status === 'rejected') {
console.warn('a2a-server build failed:', a2aResult.reason);
+33
View File
@@ -0,0 +1,33 @@
import { evalTest } from './test-helper.js';
import { expect } from 'vitest';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should create an all-day event using the optional date field',
prompt:
'Create an all-day event for 2026-05-20 titled "Company Retreat". Do not use a specific time.',
setup: async (rig) => {
rig.addTestMcpServer('workspace-server', 'google-workspace');
},
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
console.log('TOOL LOGS:', JSON.stringify(toolLogs, null, 2));
const createEventCall = toolLogs.find(
(log) =>
log.toolRequest.name === 'mcp_workspace-server_calendar.createEvent',
);
expect(createEventCall).toBeDefined();
const args = JSON.parse(createEventCall!.toolRequest.args);
expect(args?.start).toHaveProperty('date');
expect(args?.start).not.toHaveProperty('dateTime');
expect(args?.start?.date).toBe('2026-05-20');
expect(args?.end).toHaveProperty('date');
expect(args?.end).not.toHaveProperty('dateTime');
},
});
+24 -4
View File
@@ -76,10 +76,30 @@ export class LLMJudge {
for (const res of rawResults) {
// Remove any punctuation the model might have appended
const cleanRes = res.replace(/[^A-Z]/g, '');
if (cleanRes.startsWith('YES')) yes++;
else if (cleanRes.startsWith('NO')) no++;
else other++;
const cleanRes = res.replace(/[^A-Z ]/g, '');
if (
cleanRes.includes('THE ANSWER IS YES') ||
cleanRes.includes('ANSWER IS YES') ||
cleanRes.endsWith('YES')
) {
yes++;
} else if (
cleanRes.includes('THE ANSWER IS NO') ||
cleanRes.includes('ANSWER IS NO') ||
cleanRes.endsWith('NO')
) {
no++;
} else if (cleanRes.trim() === 'YES') {
yes++;
} else if (cleanRes.trim() === 'NO') {
no++;
} else {
// Fallback: look for YES or NO as standalone words or at the end
const words = cleanRes.split(/\s+/);
if (words.includes('YES')) yes++;
else if (words.includes('NO')) no++;
else other++;
}
}
// Pass if YES > NO and YES > OTHER (plurality)
+243 -188
View File
@@ -14,219 +14,274 @@ import type { FakeResponse, HistoryTurn } from '@google/gemini-cli-core';
describe('Context Management Fidelity E2E', () => {
let rig: TestRig;
function generateRandomString(length: number): string {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(
Math.floor(Math.random() * characters.length),
);
}
return result;
}
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should reproduce the exact context working buffer on resume', async () => {
// Mock responses to trigger GC (summarization)
const snapshotResponse: FakeResponse = {
method: 'generateContent',
response: {
candidates: [
{
content: {
parts: [
{
text: JSON.stringify({
new_facts: ['GC Triggered.'],
new_constraints: [],
new_tasks: [],
resolved_task_ids: [],
obsolete_fact_indices: [],
obsolete_constraint_indices: [],
chronological_summary: 'Snapshot created.',
}),
},
],
role: 'model',
},
finishReason: FinishReason.STOP,
index: 0,
},
],
} as unknown as GenerateContentResponse,
};
const countTokensResponse: FakeResponse = {
method: 'countTokens',
response: { totalTokens: 50000 },
};
const streamResponse = (text: string): FakeResponse => ({
method: 'generateContentStream',
response: [
{
it(
'should reproduce the exact context working buffer on resume',
{ timeout: 300000 },
async () => {
// Mock responses to trigger GC (summarization)
const snapshotResponse: FakeResponse = {
method: 'generateContent',
response: {
candidates: [
{
content: { parts: [{ text }], role: 'model' },
content: {
parts: [
{
text: JSON.stringify({
new_facts: ['GC Triggered.'],
new_constraints: [],
new_tasks: [],
resolved_task_ids: [],
obsolete_fact_indices: [],
obsolete_constraint_indices: [],
chronological_summary: 'Snapshot created.',
}),
},
],
role: 'model',
},
finishReason: FinishReason.STOP,
index: 0,
},
],
},
] as unknown as GenerateContentResponse[],
});
} as unknown as GenerateContentResponse,
};
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
const filePath = path.join(rig.testDir!, fileName);
const countTokensResponse: FakeResponse = {
method: 'countTokens',
response: { totalTokens: 1000 },
};
const streamResponse = (text: string): FakeResponse => ({
method: 'generateContentStream',
response: [
{
candidates: [
{
content: { parts: [{ text }], role: 'model' },
finishReason: FinishReason.STOP,
index: 0,
},
],
},
] as unknown as GenerateContentResponse[],
});
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
const filePath = path.join(rig.testDir!, fileName);
fs.writeFileSync(
filePath,
mocks.map((m) => JSON.stringify(m)).join('\n'),
);
return filePath;
};
await rig.setup('context-fidelity', {
settings: {
experimental: {
stressTestProfile: true, // Lowers thresholds to trigger GC easily
},
},
});
const traceDir = path.join(rig.testDir!, 'traces');
fs.mkdirSync(traceDir, { recursive: true });
const traceLog = path.join(traceDir, 'trace.log');
// Ignore trace and response files to keep environment context clean and stable
fs.writeFileSync(
filePath,
mocks.map((m) => JSON.stringify(m)).join('\n'),
path.join(rig.testDir!, '.geminiignore'),
'traces/\nresp*.json\ndebug.log\n',
);
return filePath;
};
await rig.setup('context-fidelity', {
settings: {
experimental: {
stressTestProfile: true, // Lowers thresholds to trigger GC easily
},
},
});
const commonEnv = {
GEMINI_API_KEY: 'mock-key',
GEMINI_CONTEXT_TRACE_DIR: traceDir,
GEMINI_CONTEXT_TRACE_ENABLED: 'true',
GEMINI_DEBUG_LOG_FILE: path.join(rig.testDir!, 'debug.log'),
};
const massivePayload = 'X'.repeat(50000);
const traceDir = path.join(rig.testDir!, 'traces');
fs.mkdirSync(traceDir, { recursive: true });
const traceLog = path.join(traceDir, 'trace.log');
const commonEnv = {
GEMINI_API_KEY: 'mock-key',
GEMINI_CONTEXT_TRACE_DIR: traceDir,
GEMINI_CONTEXT_TRACE_ENABLED: 'true',
GEMINI_DEBUG_LOG_FILE: path.join(rig.testDir!, 'debug.log'),
};
const runMocks: FakeResponse[] = [
streamResponse('Ack 1'),
streamResponse('Ack 2'),
streamResponse('Ack 3'),
streamResponse('Ack 4'),
streamResponse('Ack 5'),
];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
// Turn 1: Initial massive payload to put pressure
await rig.run({
args: [
'--debug',
'--fake-responses-non-strict',
setupResponses('resp1.json', runMocks),
],
stdin: 'Turn 1: ' + massivePayload,
env: commonEnv,
});
// Turn 2: Another turn, resuming Turn 1
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 2: ' + massivePayload,
env: commonEnv,
});
// Turn 3: Third turn to force GC, resuming Turn 2
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
],
stdin: 'Turn 3: ' + massivePayload,
env: commonEnv,
});
// Extract the rendered context asset from the log
const getRenderedContext = (logContent: string): HistoryTurn[] | null => {
const lines = logContent.split('\n');
const renderLines = lines.filter(
(l) =>
l.includes('[Render] Render Sanitized Context for LLM') ||
l.includes('[Render] Render Context for LLM'),
);
if (renderLines.length === 0) return null;
const lastRender = renderLines[renderLines.length - 1];
const detailsMatch = lastRender.match(/\| Details: (.*)$/);
if (!detailsMatch) return null;
const details = JSON.parse(detailsMatch[1]);
const assetInfo =
details.renderedContextSanitized || details.renderedContext;
if (assetInfo && assetInfo.$asset) {
const assetPath = path.join(traceDir, 'assets', assetInfo.$asset);
return JSON.parse(fs.readFileSync(assetPath, 'utf-8'));
const runMocks: FakeResponse[] = [
streamResponse('Ack 1'),
streamResponse('Ack 2'),
streamResponse('Ack 3'),
streamResponse('Ack 4'),
streamResponse('Ack 5'),
streamResponse('Ack 6'),
streamResponse('Ack 7'),
streamResponse('Ack 8'),
streamResponse('Ack 9'),
streamResponse('Ack 10'),
streamResponse('Ack 11'),
streamResponse('Ack 12'),
];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
return assetInfo;
};
const log1 = fs.readFileSync(traceLog, 'utf-8');
const contextBeforeExit = getRenderedContext(log1);
expect(contextBeforeExit).toBeDefined();
console.log(
'Context Before Exit (First 2 turns):',
JSON.stringify(contextBeforeExit!.slice(0, 2), null, 2),
);
// Turns 1-10: Build up history
for (let i = 1; i <= 10; i++) {
await rig.run({
args: [
'--debug',
i === 1 ? '' : '--resume',
i === 1 ? '' : 'latest',
'--fake-responses-non-strict',
setupResponses(`resp_init_${i}.json`, runMocks),
].filter(Boolean),
stdin: `Turn ${i}: ` + generateRandomString(900),
env: commonEnv,
});
}
// Turn 4: Resume and run a small command
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp4.json', runMocks),
'continue',
],
env: commonEnv,
});
// Turn 11: Penultimate turn
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 11: ' + generateRandomString(900),
env: commonEnv,
});
const log2 = fs.readFileSync(traceLog, 'utf-8');
const contextAfterResume = getRenderedContext(log2);
expect(contextAfterResume).toBeDefined();
console.log(
'Context After Resume (First 2 turns):',
JSON.stringify(contextAfterResume!.slice(0, 2), null, 2),
);
// Turn 12: Breach threshold and force GC
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
],
stdin: 'Turn 12: ' + generateRandomString(900),
env: commonEnv,
});
expect(contextAfterResume!.length).toBeGreaterThanOrEqual(
contextBeforeExit!.length,
);
// Extract the rendered context asset from the log
const getRenderedContext = (logContent: string): HistoryTurn[] | null => {
const lines = logContent.split('\n');
const renderLines = lines.filter(
(l) =>
l.includes('[Render] Render Sanitized Context for LLM') ||
l.includes('[Render] Render Context for LLM'),
);
if (renderLines.length === 0) return null;
for (let i = 0; i < contextBeforeExit!.length; i++) {
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
expect(contextAfterResume![i].content).toEqual(
contextBeforeExit![i].content,
const lastRender = renderLines[renderLines.length - 1];
const detailsMatch = lastRender.match(/\| Details: (.*)$/);
if (!detailsMatch) return null;
const details = JSON.parse(detailsMatch[1]);
const assetInfo =
details.renderedContextSanitized || details.renderedContext;
if (assetInfo && assetInfo.$asset) {
const assetPath = path.join(traceDir, 'assets', assetInfo.$asset);
return JSON.parse(fs.readFileSync(assetPath, 'utf-8'));
}
return assetInfo;
};
const log1 = fs.readFileSync(traceLog, 'utf-8');
const contextBeforeExit = getRenderedContext(log1);
expect(contextBeforeExit).toBeDefined();
console.log(
'Context Before Exit (First 2 turns):',
JSON.stringify(contextBeforeExit!.slice(0, 2), null, 2),
);
}
// Most importantly, synthetic IDs (like summaries) must be stable.
const syntheticTurns = contextBeforeExit!.filter(
(t: HistoryTurn) => t.id && t.id.length === 32,
); // deriveStableId produces 32-char hex
expect(syntheticTurns.length).toBeGreaterThan(0);
// Turn 4: Resume and run a small command
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp4.json', runMocks),
'continue',
],
env: commonEnv,
});
const syntheticTurnsAfter = contextAfterResume!.filter(
(t: HistoryTurn) => t.id && t.id.length === 32,
);
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
syntheticTurns.length,
);
const log2 = fs.readFileSync(traceLog, 'utf-8');
const contextAfterResume = getRenderedContext(log2);
expect(contextAfterResume).toBeDefined();
console.log(
'Context After Resume (First 2 turns):',
JSON.stringify(contextAfterResume!.slice(0, 2), null, 2),
);
// Check if the first synthetic turn is identical
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content);
});
expect(contextAfterResume!.length).toBeGreaterThanOrEqual(
contextBeforeExit!.length,
);
// The environment context is intentionally refreshed on resume to reflect
// the current state of the workspace (e.g. new files, current date).
// We allow its content to differ but ensure it's still an environment context.
const isEnvContext = (turn: HistoryTurn) =>
turn.content.parts?.some((p) => p.text?.includes('<session_context>'));
for (let i = 0; i < contextBeforeExit!.length; i++) {
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
const turnBefore = contextBeforeExit![i];
const turnAfter = contextAfterResume![i];
if (isEnvContext(turnBefore)) {
expect(isEnvContext(turnAfter)).toBe(true);
continue;
}
expect(turnAfter.content).toEqual(turnBefore.content);
}
// Most importantly, synthetic IDs (like summaries) must be stable.
const syntheticTurns = contextBeforeExit!.filter(
(t: HistoryTurn) =>
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
(t.id && t.id.length === 32),
);
expect(syntheticTurns.length).toBeGreaterThan(0);
const syntheticTurnsAfter = contextAfterResume!.filter(
(t: HistoryTurn) =>
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
(t.id && t.id.length === 32),
);
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
syntheticTurns.length,
);
// Check if the first synthetic turn is identical (with relaxation for environment context)
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
if (isEnvContext(syntheticTurns[0])) {
expect(isEnvContext(syntheticTurnsAfter[0])).toBe(true);
} else {
expect(syntheticTurnsAfter[0].content).toEqual(
syntheticTurns[0].content,
);
}
},
);
});
+9 -16
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"workspaces": [
"packages/*"
],
@@ -6078,12 +6078,6 @@
"node": ">=8"
}
},
"node_modules/chardet": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz",
"integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==",
"license": "MIT"
},
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -18123,7 +18117,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.19.0",
@@ -18252,7 +18246,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18400,7 +18394,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18434,7 +18428,6 @@
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"command-exists": "^1.2.9",
"diff": "^8.0.3",
@@ -18681,7 +18674,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18696,7 +18689,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18727,7 +18720,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18759,7 +18752,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"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.44.0-nightly.20260512.g022e8baef"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.47.0-nightly.20260602.gcfcecebe8"
},
"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.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -294,6 +294,66 @@ describe('Task', () => {
]);
});
it('should capture usageMetadata on Finished event and include it in final status update', 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 for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const finishedEvent = {
type: GeminiEventType.Finished,
value: {
reason: 'STOP',
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
},
};
await task.acceptAgentMessage(finishedEvent);
expect(task.usageMetadata).toEqual({
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
});
task.setTaskStateAndPublishUpdate(
'input-required',
{ kind: CoderAgentEvent.StateChangeEvent },
undefined,
undefined,
true, // final
);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
final: true,
metadata: expect.objectContaining({
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
}),
}),
);
});
it('should update modelInfo and reflect it in metadata and status updates', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
+21
View File
@@ -89,6 +89,12 @@ export class Task {
currentAgentMessageId = uuidv4();
promptCount = 0;
autoExecute: boolean;
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
cachedContentTokenCount?: number;
};
private get isYoloMatch(): boolean {
return (
this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
@@ -274,6 +280,7 @@ export class Task {
userTier?: UserTierId;
error?: string;
traceId?: string;
usageMetadata?: Task['usageMetadata'];
} = {
coderAgent: coderAgentMessage,
model: this.modelInfo || this.config.getModel(),
@@ -288,6 +295,10 @@ export class Task {
metadata.traceId = traceId;
}
if (final && this.usageMetadata) {
metadata.usageMetadata = this.usageMetadata;
}
return {
kind: 'status-update',
taskId: this.id,
@@ -857,8 +868,18 @@ export class Task {
break;
case GeminiEventType.Finished:
logger.info(`[Task ${this.id}] Agent finished its turn.`);
// Capture the usage metadata when the stream finishes
if (
event.value &&
typeof event.value === 'object' &&
'usageMetadata' in event.value
) {
this.usageMetadata = event.value
.usageMetadata as typeof this.usageMetadata;
}
break;
case GeminiEventType.ModelInfo:
this.usageMetadata = undefined;
this.modelInfo = event.value;
break;
case GeminiEventType.Retry:
+17 -10
View File
@@ -17,17 +17,24 @@ import {
// --- Global Entry Point ---
// Suppress known race condition error in node-pty on Windows
// Suppress known race condition error in node-pty on Windows and Linux
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
if (
process.platform === 'win32' &&
error instanceof Error &&
error.message === 'Cannot resize a pty that has already exited'
) {
// This error happens on Windows with node-pty when resizing a pty that has just exited.
// It is a race condition in node-pty that we cannot prevent, so we silence it.
return;
if (error instanceof Error) {
const message = error.message || '';
const isPtyResizeError =
message === 'Cannot resize a pty that has already exited';
const isEbadfError =
message.includes('EBADF') ||
(error as { code?: string }).code === 'EBADF';
const isFromNodePty =
error.stack?.includes('node-pty') || error.stack?.includes('PtyResize');
if ((isPtyResizeError || isEbadfError) && isFromNodePty) {
// This error happens with node-pty when resizing a pty that has just exited.
// It is a race condition in node-pty that we cannot prevent, so we silence it.
return;
}
}
// For other errors, we rely on the default behavior, but since we attached a listener,
@@ -126,7 +133,7 @@ async function run() {
while (true) {
try {
const exitCode = await runner();
if (exitCode !== RELAUNCH_EXIT_CODE) {
if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) {
process.exit(exitCode);
}
} catch (error: unknown) {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"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.44.0-nightly.20260512.g022e8baef"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.47.0-nightly.20260602.gcfcecebe8"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+5
View File
@@ -100,6 +100,11 @@ describe('GeminiAgent Session Resume', () => {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
getMessageBus: vi.fn().mockReturnValue({
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
@@ -71,6 +71,7 @@ describe('GeminiAgent - RPC Dispatcher', () => {
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
+326
View File
@@ -26,6 +26,10 @@ import {
InvalidStreamError,
GeminiEventType,
type ServerGeminiStreamEvent,
PolicyDecision,
MessageBusType,
type ToolConfirmationRequest,
DiscoveredMCPTool,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { type Part, FinishReason } from '@google/genai';
@@ -139,9 +143,13 @@ describe('Session', () => {
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
getPolicyEngine: vi.fn().mockReturnValue({
check: vi.fn(),
}),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
waitForMcpInit: vi.fn(),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
@@ -707,4 +715,322 @@ describe('Session', () => {
}),
);
});
describe('Policy Handling', () => {
it('should auto-approve tool calls when PolicyEngine returns ALLOW', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Trigger the subscription handler
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
expect(handler).toBeDefined();
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id',
confirmed: true,
requiresUserConfirmation: false,
}),
);
});
it('should request user confirmation when PolicyEngine returns ASK_USER', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-2',
toolCall: { name: 'rm', args: { path: '/' } },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-2',
confirmed: false,
requiresUserConfirmation: true,
}),
);
});
it('should deny tool calls when PolicyEngine returns DENY', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.DENY,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-3',
toolCall: { name: 'forbidden', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-3',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should pass subagent and trusted tool info to PolicyEngine', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Mock tool in registry with trusted annotations
const trustedAnnotations = { safe: true };
mockToolRegistry.getTool.mockReturnValue({
name: 'ls',
toolAnnotations: trustedAnnotations,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-trusted',
toolCall: { name: 'ls', args: {} },
subagent: 'restricted-subagent',
serverName: 'spoofed-server', // Should be ignored
toolAnnotations: { malicious: true }, // Should be ignored
});
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
expect.anything(),
undefined, // serverName for non-MCP tool
trustedAnnotations,
'restricted-subagent',
);
});
it('should handle exceptions in PolicyEngine by failing closed', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockRejectedValue(
new Error('Policy check failed'),
);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-error',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-error',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should fail closed when PolicyEngine is missing', async () => {
(mockConfig.getPolicyEngine as Mock).mockReturnValue(undefined);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-no-engine',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-no-engine',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should handle missing tool name in request by failing closed', async () => {
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-no-name',
toolCall: { name: '', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-no-name',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should trim tool name before lookup and validation', async () => {
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-whitespace',
toolCall: { name: ' ', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-whitespace',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should pass serverName from DiscoveredMCPTool to PolicyEngine', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Mock tool in registry as a DiscoveredMCPTool instance
const mcpTool = {
name: 'mcp_server_tool',
serverName: 'test-server',
toolAnnotations: { mcp: true },
};
Object.setPrototypeOf(mcpTool, DiscoveredMCPTool.prototype);
mockToolRegistry.getTool.mockReturnValue(mcpTool);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-mcp',
toolCall: { name: 'mcp_server_tool', args: {} },
});
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
expect.anything(),
'test-server',
{ mcp: true },
undefined,
);
});
it('should fail closed and deny unknown tools', async () => {
mockToolRegistry.getTool.mockReturnValue(undefined);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-unknown',
toolCall: { name: 'unknown_tool', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-unknown',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
});
});
+210 -88
View File
@@ -34,6 +34,11 @@ import {
isNodeError,
REFERENCE_CONTENT_START,
InvalidStreamError,
MessageBusType,
PolicyDecision,
type ToolConfirmationRequest,
resolveAtCommandPath,
type ResolvedAtCommandPath,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import type { Part, FunctionCall } from '@google/genai';
@@ -61,6 +66,7 @@ export class Session {
private pendingPrompt: AbortController | null = null;
private commandHandler = new CommandHandler();
private callIdCounter = 0;
private readonly disposeController = new AbortController();
private generateCallId(name: string): string {
return `${name}-${Date.now()}-${++this.callIdCounter}`;
@@ -77,8 +83,98 @@ export class Session {
CoreEvent.ApprovalModeChanged,
this.handleApprovalModeChanged,
);
// Subscribe to tool confirmation requests to handle policy checks (e.g. auto-allowing safe shell commands)
this.context.config
.getMessageBus()
?.subscribe(
MessageBusType.TOOL_CONFIRMATION_REQUEST,
this.handleToolConfirmationRequest,
{ signal: this.disposeController.signal },
);
}
private handleToolConfirmationRequest = async (
request: ToolConfirmationRequest,
) => {
try {
const policyEngine = this.context.config.getPolicyEngine?.();
const messageBus = this.context.config.getMessageBus();
if (!messageBus) {
return;
}
if (!policyEngine) {
debugLogger.warn(
'Policy engine missing. Denying tool confirmation request.',
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const toolName = request.toolCall.name?.trim();
if (!toolName) {
debugLogger.warn(
'Tool confirmation request missing tool name. Denying.',
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const tool = this.context.toolRegistry.getTool(toolName);
if (!tool) {
debugLogger.warn(
`Tool confirmation request for unknown tool: ${toolName}. Denying.`,
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const serverName =
tool instanceof DiscoveredMCPTool ? tool.serverName : undefined;
const toolAnnotations = tool.toolAnnotations;
const result = await policyEngine.check(
request.toolCall,
serverName,
toolAnnotations,
request.subagent,
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: result.decision === PolicyDecision.ALLOW,
requiresUserConfirmation: result.decision === PolicyDecision.ASK_USER,
});
} catch (error) {
debugLogger.error('Error handling tool confirmation request:', error);
// Fail closed on exception
await this.context.config.getMessageBus()?.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
}
};
private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => {
if (payload.sessionId === this.id) {
void this.sendUpdate({
@@ -96,6 +192,7 @@ export class Session {
CoreEvent.ApprovalModeChanged,
this.handleApprovalModeChanged,
);
this.disposeController.abort();
}
async cancelPendingPrompt(): Promise<void> {
@@ -928,99 +1025,120 @@ export class Session {
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
let readDirectly = false;
try {
const absolutePath = path.resolve(
const result = await resolveAtCommandPath(
pathName,
this.context.config,
(msg) => this.debug(msg),
);
let validationError: string | null = null;
let absolutePath: string;
let resolved: ResolvedAtCommandPath | undefined;
if (result.status === 'resolved') {
resolved = result.resolved;
absolutePath = resolved.absolutePath;
} else if (result.status === 'unauthorized') {
absolutePath = result.absolutePath;
validationError = result.error;
} else if (result.status === 'invalid') {
// Already logged in resolveAtCommandPath
continue;
} else {
// Result is not_found.
// We still check if it's an unauthorized absolute path that we can ask permission for,
// specifically for paths that are completely outside the root and not even in any workspace directory.
// For relative paths not found anywhere, we resolve relative to targetDir for permission check.
absolutePath = path.resolve(
this.context.config.getTargetDir(),
pathName,
);
}
let validationError = this.context.config.validatePathAccess(
absolutePath,
'read',
);
// We ask the user for explicit permission to read them if outside sandboxed workspace boundaries (and not already authorized).
if (
validationError &&
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
) {
try {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
const params = {
sessionId: this.id,
options: [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow once',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Deny',
kind: 'reject_once',
},
] as acp.PermissionOption[],
toolCall: {
toolCallId: syntheticCallId,
status: 'pending',
title: `Allow access to absolute path: ${pathName}`,
content: [
{
type: 'content',
content: {
type: 'text',
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
},
},
],
locations: [],
kind: 'read',
if (
!resolved &&
validationError &&
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
) {
try {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
const params = {
sessionId: this.id,
options: [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow once',
kind: 'allow_once',
},
};
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
this.context.config
.getWorkspaceContext()
.addReadOnlyPath(absolutePath);
validationError = null;
} else {
this.debug(
`Direct read authorization denied for absolute path ${pathName}`,
);
directContents.push({
spec: pathName,
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
});
continue;
}
}
} catch (error) {
this.debug(
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Deny',
kind: 'reject_once',
},
] as acp.PermissionOption[],
toolCall: {
toolCallId: syntheticCallId,
status: 'pending',
title: `Allow access to absolute path: ${pathName}`,
content: [
{
type: 'content',
content: {
type: 'text',
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
},
},
],
locations: [],
kind: 'read',
},
});
}
}
};
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
this.context.config
.getWorkspaceContext()
.addReadOnlyPath(absolutePath);
validationError = null;
} else {
this.debug(
`Direct read authorization denied for absolute path ${pathName}`,
);
directContents.push({
spec: pathName,
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
});
continue;
}
}
} catch (error) {
this.debug(
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
},
});
}
}
try {
if (!validationError) {
// If it's an absolute path that is authorized (e.g. added via readOnlyPaths),
// read it directly to avoid ReadManyFilesTool absolute path resolution issues.
@@ -1033,7 +1151,9 @@ export class Session {
!readDirectly
) {
try {
const stats = await fs.stat(absolutePath);
const stats = resolved
? resolved.stats
: await fs.stat(absolutePath);
if (stats.isFile()) {
const fileReadResult = await processSingleFileContent(
absolutePath,
@@ -1092,7 +1212,9 @@ export class Session {
}
if (!readDirectly) {
const stats = await fs.stat(absolutePath);
const stats = resolved
? resolved.stats
: await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
+5 -10
View File
@@ -79,6 +79,7 @@ describe('AcpSessionManager', () => {
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
@@ -216,13 +217,12 @@ describe('AcpSessionManager', () => {
);
});
it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
it('should NOT include retired preview models (none) in available models', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
const response = await manager.newSession(
{
@@ -232,14 +232,9 @@ describe('AcpSessionManager', () => {
{},
);
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: 'gemini-3.1-flash-lite-preview',
name: 'gemini-3.1-flash-lite-preview',
}),
]),
);
const modelIds =
response.models?.availableModels?.map((m) => m.modelId) ?? [];
expect(modelIds).not.toContain('none');
});
it('should return modes with plan mode when plan is enabled', async () => {
+7 -7
View File
@@ -18,7 +18,7 @@ import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
getDisplayString,
AuthType,
ToolConfirmationOutcome,
@@ -265,8 +265,7 @@ export function buildAvailableModels(
const preferredModel = config.getModel() || GEMINI_MODEL_ALIAS_AUTO;
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
@@ -278,7 +277,7 @@ export function buildAvailableModels(
) {
const options = config.getModelConfigService().getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useGemini3_5Flash,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
});
@@ -297,6 +296,7 @@ export function buildAvailableModels(
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
useGemini3_5Flash,
),
},
];
@@ -336,10 +336,10 @@ export function buildAvailableModels(
},
];
if (useGemini31FlashLite) {
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
value: PREVIEW_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL),
});
}
+254
View File
@@ -475,4 +475,258 @@ describe('mcp list command', () => {
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should block servers excluded by user settings even if workspace settings override/clear the excluded list', async () => {
const mockSettings = createMockSettings({
user: {
path: '/user/settings.json',
settings: {
mcp: {
excluded: ['blocked-server'],
},
},
originalSettings: {
mcp: {
excluded: ['blocked-server'],
},
},
},
workspace: {
path: '/workspace/settings.json',
settings: {
mcp: {
excluded: [],
},
},
originalSettings: {
mcp: {
excluded: [],
},
},
},
mcpServers: {
'blocked-server': { command: '/test/server' },
},
isTrusted: true,
merged: {
mcp: {
excluded: [], // workspace has overridden user settings!
},
mcpServers: {
'blocked-server': { command: '/test/server' },
},
},
});
mockedLoadSettings.mockReturnValue(mockSettings);
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'blocked-server: /test/server (stdio) - Blocked',
),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should block servers case-insensitively when excluded', async () => {
const mockSettings = createMockSettings({
user: {
path: '/user/settings.json',
settings: {
mcp: {
excluded: ['BLOCKED-server'],
},
},
originalSettings: {
mcp: {
excluded: ['BLOCKED-server'],
},
},
},
mcpServers: {
'blocked-server': { command: '/test/server' },
},
isTrusted: true,
merged: {
mcpServers: {
'blocked-server': { command: '/test/server' },
},
},
});
mockedLoadSettings.mockReturnValue(mockSettings);
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'blocked-server: /test/server (stdio) - Blocked',
),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should restrict allowed servers to the intersection of all defined allowlists', async () => {
const mockSettings = createMockSettings({
user: {
path: '/user/settings.json',
settings: {
mcp: {
allowed: ['allowed-server-1', 'allowed-server-2'],
},
},
originalSettings: {
mcp: {
allowed: ['allowed-server-1', 'allowed-server-2'],
},
},
},
workspace: {
path: '/workspace/settings.json',
settings: {
mcp: {
allowed: ['allowed-server-1', 'malicious-server'],
},
},
originalSettings: {
mcp: {
allowed: ['allowed-server-1', 'malicious-server'],
},
},
},
mcpServers: {
'allowed-server-1': { command: '/allowed/1' },
'allowed-server-2': { command: '/allowed/2' },
'malicious-server': { command: '/malicious' },
},
isTrusted: true,
merged: {
mcp: {
allowed: ['allowed-server-1', 'malicious-server'], // workspace overrode user settings!
},
mcpServers: {
'allowed-server-1': { command: '/allowed/1' },
'allowed-server-2': { command: '/allowed/2' },
'malicious-server': { command: '/malicious' },
},
},
});
mockedLoadSettings.mockReturnValue(mockSettings);
mockClient.connect.mockResolvedValue(undefined);
mockClient.ping.mockResolvedValue(undefined);
await listMcpServers();
// allowed-server-1 is in the intersection, so it should connect
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'allowed-server-1: /allowed/1 (stdio) - Connected',
),
);
// allowed-server-2 and malicious-server are not in the intersection, so they should be Blocked
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'allowed-server-2: /allowed/2 (stdio) - Blocked',
),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'malicious-server: /malicious (stdio) - Blocked',
),
);
expect(mockedCreateTransport).toHaveBeenCalledTimes(1);
expect(mockedCreateTransport).toHaveBeenCalledWith(
'allowed-server-1',
expect.any(Object),
false,
expect.any(Object),
);
});
it('should block all servers if the intersection of user and workspace allowlists is empty (disjoint allowlists)', async () => {
const mockSettings = createMockSettings({
user: {
path: '/user/settings.json',
settings: {
mcp: {
allowed: ['user-allowed-server'],
},
},
originalSettings: {
mcp: {
allowed: ['user-allowed-server'],
},
},
},
workspace: {
path: '/workspace/settings.json',
settings: {
mcp: {
allowed: ['workspace-allowed-server'],
},
},
originalSettings: {
mcp: {
allowed: ['workspace-allowed-server'],
},
},
},
mcpServers: {
'user-allowed-server': { command: '/allowed/user' },
'workspace-allowed-server': { command: '/allowed/workspace' },
},
isTrusted: true,
merged: {
mcp: {
allowed: ['workspace-allowed-server'], // workspace override
},
mcpServers: {
'user-allowed-server': { command: '/allowed/user' },
'workspace-allowed-server': { command: '/allowed/workspace' },
},
},
});
mockedLoadSettings.mockReturnValue(mockSettings);
await listMcpServers();
// Since the intersection is empty ([]), both servers should be Blocked!
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'user-allowed-server: /allowed/user (stdio) - Blocked',
),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining(
'workspace-allowed-server: /allowed/workspace (stdio) - Blocked',
),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
it('should block all servers if allowlist is configured as empty array []', async () => {
const mockSettings = createMockSettings({
mcp: {
allowed: [], // empty allowlist configured!
},
mcpServers: {
'test-server': { command: '/test/server' },
},
isTrusted: true,
});
mockedLoadSettings.mockReturnValue(mockSettings);
await listMcpServers();
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('test-server: /test/server (stdio) - Blocked'),
);
expect(mockedCreateTransport).not.toHaveBeenCalled();
});
});
+12 -2
View File
@@ -159,12 +159,16 @@ async function getServerStatus(
server: MCPServerConfig,
isTrusted: boolean,
activeSettings: MergedSettings,
consolidatedExcluded: string[],
consolidatedAllowed: string[] | undefined,
): Promise<MCPServerStatus> {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const loadResult = await canLoadServer(serverName, {
adminMcpEnabled: activeSettings.admin?.mcp?.enabled ?? true,
allowedList: activeSettings.mcp?.allowed,
excludedList: activeSettings.mcp?.excluded,
allowedList: consolidatedAllowed,
excludedList:
consolidatedExcluded.length > 0 ? consolidatedExcluded : undefined,
enablement: mcpEnablementManager.getEnablementCallbacks(),
});
@@ -227,6 +231,10 @@ export async function listMcpServers(
);
}
const consolidatedExcluded =
loadedSettings.getConsolidatedExcludedMcpServers();
const consolidatedAllowed = loadedSettings.getConsolidatedAllowedMcpServers();
debugLogger.log('Configured MCP servers:\n');
for (const serverName of serverNames) {
@@ -237,6 +245,8 @@ export async function listMcpServers(
server,
loadedSettings.isTrusted,
activeSettings,
consolidatedExcluded,
consolidatedAllowed,
);
let statusIndicator = '';
+16 -3
View File
@@ -576,6 +576,7 @@ export interface LoadCliConfigOptions {
};
worktreeSettings?: WorktreeSettings;
skipExtensions?: boolean;
loadedSettings?: LoadedSettings;
}
export async function loadCliConfig(
@@ -584,7 +585,12 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise<Config> {
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
const {
cwd = process.cwd(),
projectHooks,
skipExtensions = false,
loadedSettings,
} = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
@@ -936,6 +942,8 @@ export async function loadCliConfig(
let profileSelector: string | undefined = undefined;
if (settings.experimental?.stressTestProfile) {
profileSelector = 'stressTestProfile';
} else if (settings.experimental?.powerUserProfile) {
profileSelector = 'powerUserProfile';
} else if (
settings.experimental?.generalistProfile ||
settings.experimental?.contextManagement
@@ -983,12 +991,17 @@ export async function loadCliConfig(
agents: settings.agents,
adminSkillsEnabled,
allowedMcpServers: mcpEnabled
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
? (argv.allowedMcpServerNames ??
(loadedSettings
? loadedSettings.getConsolidatedAllowedMcpServers()
: settings.mcp?.allowed))
: undefined,
blockedMcpServers: mcpEnabled
? argv.allowedMcpServerNames
? undefined
: settings.mcp?.excluded
: loadedSettings
? loadedSettings.getConsolidatedExcludedMcpServers()
: settings.mcp?.excluded
: undefined,
blockedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.blocked,
@@ -119,7 +119,7 @@ export async function canLoadServer(
}
// 2. Allowlist check
if (config.allowedList && config.allowedList.length > 0) {
if (config.allowedList !== undefined) {
const { found, deprecationWarning } = isInSettingsList(
normalizedId,
config.allowedList,
+71
View File
@@ -1109,6 +1109,77 @@ describe('Settings Loading and Merging', () => {
});
});
describe('LoadedSettings MCP consolidation', () => {
it('should consolidate mcp excluded list across all scopes', () => {
const loaded = new LoadedSettings(
{
path: '',
settings: { mcp: { excluded: ['system-excluded'] } },
originalSettings: {},
},
{
path: '',
settings: { mcp: { excluded: ['defaults-excluded'] } },
originalSettings: {},
},
{
path: '',
settings: { mcp: { excluded: ['user-excluded'] } },
originalSettings: {},
},
{
path: '',
settings: { mcp: { excluded: ['workspace-excluded'] } },
originalSettings: {},
},
true,
);
expect(loaded.getConsolidatedExcludedMcpServers()).toEqual([
'system-excluded',
'defaults-excluded',
'user-excluded',
'workspace-excluded',
]);
});
it('should consolidate allowed mcp list via case-insensitive intersection', () => {
const loaded = new LoadedSettings(
{
path: '',
settings: { mcp: { allowed: ['Server-A', 'Server-B'] } },
originalSettings: {},
},
{
path: '',
settings: { mcp: { allowed: ['server-a', 'Server-C'] } },
originalSettings: {},
},
{ path: '', settings: {}, originalSettings: {} }, // no allowlist in user
{
path: '',
settings: { mcp: { allowed: ['SERVER-A', 'Server-D'] } },
originalSettings: {},
},
true,
);
expect(loaded.getConsolidatedAllowedMcpServers()).toEqual(['Server-A']);
});
it('should return undefined allowed list if no scopes define one', () => {
const loaded = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
);
expect(loaded.getConsolidatedAllowedMcpServers()).toBeUndefined();
});
});
describe('compressionThreshold settings', () => {
it.each([
{
+45
View File
@@ -509,6 +509,51 @@ export class LoadedSettings {
this._remoteAdminSettings = { admin };
this._merged = this.computeMergedSettings();
}
/**
* Returns a consolidated list of excluded MCP servers across all settings files.
*/
getConsolidatedExcludedMcpServers(): string[] {
const scopes = [
this.system,
this.systemDefaults,
this.user,
this.workspace,
];
return scopes.flatMap((scope) => {
const excluded = scope?.settings?.mcp?.excluded;
return Array.isArray(excluded) ? excluded : [];
});
}
/**
* Returns a consolidated list of allowed MCP servers (via intersection of all defined lists).
*/
getConsolidatedAllowedMcpServers(): string[] | undefined {
const scopes = [
this.system,
this.systemDefaults,
this.user,
this.workspace,
];
const definedAllowlists = scopes.flatMap((scope) => {
const allowed = scope?.settings?.mcp?.allowed;
return Array.isArray(allowed) ? [allowed] : [];
});
if (definedAllowlists.length === 0) {
return undefined;
}
return definedAllowlists.reduce((acc, current) => {
const normalizedCurrent = new Set(
current.map((item) => item.toLowerCase().trim()),
);
return acc.filter((item) =>
normalizedCurrent.has(item.toLowerCase().trim()),
);
});
}
}
function findEnvFile(
+27 -2
View File
@@ -12,6 +12,7 @@
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS,
EDITOR_OPTIONS,
AuthProviderType,
type MCPServerConfig,
type RequiredMcpServerConfig,
@@ -192,12 +193,27 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
properties: {
preferredEditor: {
type: 'string',
type: 'enum',
label: 'Preferred Editor',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The preferred editor to open files in.',
description: oneLine`
The preferred editor to open files in. Must be one of the built-in
supported identifiers. Use /editor in the CLI to pick interactively,
or leave unset to use $VISUAL/$EDITOR.
`,
showInDialog: false,
options: EDITOR_OPTIONS,
},
openEditorInNewWindow: {
type: 'boolean',
label: 'Open Editor in New Window',
category: 'General',
requiresRestart: false,
default: false,
description:
'Open VS Code-family editors in a new window when editing files.',
showInDialog: false,
},
vimMode: {
@@ -2433,6 +2449,15 @@ const SETTINGS_SCHEMA = {
'Suitable for general coding and software development tasks.',
showInDialog: true,
},
powerUserProfile: {
type: 'boolean',
label: 'Use the power user profile to manage agent contexts.',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Less cache friendly version of the generalist profile.',
showInDialog: false,
},
contextManagement: {
type: 'boolean',
label: 'Enable Context Management',
+2
View File
@@ -499,6 +499,7 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
skipExtensions: true,
loadedSettings: settings,
});
adminControlsListner.setConfig(partialConfig);
@@ -627,6 +628,7 @@ export async function main() {
config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
loadedSettings: settings,
});
loadConfigHandle?.end();
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// eslint-disable-next-line import/no-relative-packages
export { HttpProxyAgent } from '../../../../node_modules/http-proxy-agent/dist/index.js';
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// eslint-disable-next-line import/no-relative-packages
export { HttpsProxyAgent } from '../../../../node_modules/https-proxy-agent/dist/index.js';
+6 -27
View File
@@ -47,7 +47,6 @@ import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
import {
type StartupWarning,
type EditorType,
type Config,
type IdeInfo,
type IdeContext,
@@ -68,6 +67,7 @@ import {
ShellExecutionService,
saveApiKey,
debugLogger,
isValidEditorType,
coreEvents,
CoreEvent,
flattenMemory,
@@ -254,7 +254,6 @@ export const AppContainer = (props: AppContainerProps) => {
}, [mouseMode, setOptions]);
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -609,11 +608,10 @@ export const AppContainer = (props: AppContainerProps) => {
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
const getPreferredEditor = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
() => settings.merged.general.preferredEditor as EditorType,
[settings.merged.general.preferredEditor],
);
const getPreferredEditor = useCallback(() => {
const val = settings.merged.general.preferredEditor;
return isValidEditorType(val) ? val : undefined;
}, [settings.merged.general.preferredEditor]);
const buffer = useTextBuffer({
initialText: '',
@@ -1688,8 +1686,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
} = useIdeTrustListener();
const isInitialMount = useRef(true);
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -1742,8 +1738,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { handleSuspend } = useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
});
@@ -1754,21 +1748,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [ideNeedsRestart]);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
const handler = setTimeout(() => {
refreshStatic();
}, 300);
return () => {
clearTimeout(handler);
};
}, [terminalWidth, refreshStatic]);
useEffect(() => {
const unsubscribe = ideContextStore.subscribe(setIdeContextState);
setIdeContextState(ideContextStore.get());
@@ -2873,7 +2852,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
<App />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
@@ -12,7 +12,12 @@ import { MessageType } from '../types.js';
describe('helpCommand', () => {
let mockContext: CommandContext;
const originalEnv = { ...process.env };
const originalPlatform = process.platform;
const action = helpCommand.action;
if (!action) {
throw new Error('Help command has no action');
}
beforeEach(() => {
mockContext = createMockCommandContext({
@@ -23,16 +28,13 @@ describe('helpCommand', () => {
});
afterEach(() => {
process.env = { ...originalEnv };
Object.defineProperty(process, 'platform', { value: originalPlatform });
vi.unstubAllEnvs();
vi.clearAllMocks();
});
it('should add a help message to the UI history', async () => {
if (!helpCommand.action) {
throw new Error('Help command has no action');
}
await helpCommand.action(mockContext, '');
it('should add a help message to the UI history by default', async () => {
await action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -47,4 +49,85 @@ describe('helpCommand', () => {
expect(helpCommand.kind).toBe(CommandKind.BUILT_IN);
expect(helpCommand.description).toBe('For help on gemini-cli');
});
describe('Antigravity installer commands help', () => {
it('should output macOS installation command on darwin platform', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
await action(mockContext, 'install antigravity cli');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `To install the Antigravity CLI on macOS, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`,
}),
);
});
it('should output Linux installation command on linux platform', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
await action(mockContext, 'how do I install antigravity CLI');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `To install the Antigravity CLI on Linux, run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.sh | bash'`,
}),
);
});
it('should output Windows PowerShell installation command on win32 when PSModulePath is set', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', 'C:\\some\\path');
await action(mockContext, 'how do I migrate to antigravity CLI');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `To install the Antigravity CLI on Windows (PowerShell), run the following command:\n\n'irm https://antigravity.google/cli/install.ps1 | iex'`,
}),
);
});
it('should output Windows CMD installation command on win32 when PSModulePath is not set', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', '');
await action(mockContext, 'install antigravity cli');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: `To install the Antigravity CLI on Windows (Command Prompt), run the following command:\n\n'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd'`,
}),
);
});
it('should learn more message on unsupported platform', async () => {
Object.defineProperty(process, 'platform', { value: 'freebsd' });
await action(mockContext, 'install antigravity cli');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started',
}),
);
});
it('should fall back to default help if query does not contain install or migrate', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
await action(mockContext, 'antigravity cli');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HELP,
}),
);
});
});
});
+24 -1
View File
@@ -6,13 +6,36 @@
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType, type HistoryItemHelp } from '../types.js';
import { getAntigravityInstallInfo } from '../utils/antigravityUtils.js';
export const helpCommand: SlashCommand = {
name: 'help',
kind: CommandKind.BUILT_IN,
description: 'For help on gemini-cli',
autoExecute: true,
action: async (context) => {
action: async (context, args) => {
const lowerArgs = args?.toLowerCase() || '';
const hasAntigravity = lowerArgs.includes('antigravity');
const hasInstallOrMigrate =
lowerArgs.includes('install') || lowerArgs.includes('migrate');
if (hasAntigravity && hasInstallOrMigrate) {
const info = getAntigravityInstallInfo();
if (info) {
context.ui.addItem({
type: MessageType.INFO,
text: `To install the Antigravity CLI on ${info.platformName}, run the following command:\n\n'${info.installCmd}'`,
});
} else {
context.ui.addItem({
type: MessageType.INFO,
text: `Learn more about Antigravity CLI at https://antigravity.google/docs/cli-getting-started`,
});
}
return;
}
const helpItem: Omit<HistoryItemHelp, 'id'> = {
type: MessageType.HELP,
timestamp: new Date(),
@@ -175,4 +175,45 @@ describe('EditorSettingsDialog', () => {
}
expect(frame).toContain('(Also modified');
});
it('emits error feedback only once when preferredEditor is invalid', async () => {
const mockEmitFeedback = vi.fn();
vi.spyOn(
await import('@google/gemini-cli-core').then((m) => m.coreEvents),
'emitFeedback',
).mockImplementation(mockEmitFeedback);
const invalidSettings = {
forScope: (_scope: string) => ({
settings: {
general: {
preferredEditor: 'invalideditor',
},
},
}),
merged: {
general: {
preferredEditor: 'invalideditor',
},
},
} as unknown as LoadedSettings;
const { unmount } = await renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={invalidSettings}
onExit={vi.fn()}
/>,
);
await waitFor(() => {
expect(mockEmitFeedback).toHaveBeenCalledWith(
'error',
'Editor is not supported: invalideditor',
);
});
expect(mockEmitFeedback).toHaveBeenCalledTimes(1);
unmount();
});
});
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
@@ -71,14 +71,20 @@ export function EditorSettingsDialog({
(item: EditorDisplay) => item.type === currentPreference,
)
: 0;
if (editorIndex === -1) {
coreEvents.emitFeedback(
'error',
`Editor is not supported: ${currentPreference}`,
);
const isUnsupportedEditor = editorIndex === -1;
if (isUnsupportedEditor) {
editorIndex = 0;
}
useEffect(() => {
if (isUnsupportedEditor && currentPreference) {
coreEvents.emitFeedback(
'error',
`Editor is not supported: ${currentPreference}`,
);
}
}, [isUnsupportedEditor, currentPreference]);
const scopeItems: Array<{
label: string;
value: LoadableSettingScope;
@@ -131,10 +137,7 @@ export function EditorSettingsDialog({
isEditorAvailable(settings.merged.general.preferredEditor)
) {
mergedEditorName =
EDITOR_DISPLAY_NAMES[
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
settings.merged.general.preferredEditor as EditorType
];
EDITOR_DISPLAY_NAMES[settings.merged.general.preferredEditor];
}
return (
@@ -161,6 +164,7 @@ export function EditorSettingsDialog({
onSelect={handleEditorSelect}
isFocused={focusedSection === 'editor'}
key={selectedScope}
maxItemsToShow={editorItems.length}
/>
<Box marginTop={1} flexDirection="column">
@@ -19,7 +19,7 @@ import {
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
AuthType,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
@@ -47,7 +47,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
mockModelSlashCommandEvent(model);
}
},
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: 'gemini-3.1-flash-lite-preview',
PREVIEW_GEMINI_FLASH_LITE_MODEL: 'none',
};
});
@@ -67,7 +67,6 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
getGemini31LaunchedSync: () => boolean;
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getExperimentalGemma: () => boolean;
@@ -88,7 +87,6 @@ describe('<ModelDialog />', () => {
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
getGemini31LaunchedSync: mockGetGemini31LaunchedSync,
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getExperimentalGemma: () => false,
@@ -101,7 +99,6 @@ describe('<ModelDialog />', () => {
mockGetModel.mockReturnValue(GEMINI_MODEL_ALIAS_AUTO);
mockGetHasAccessToPreviewModel.mockReturnValue(false);
mockGetGemini31LaunchedSync.mockReturnValue(false);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
@@ -157,17 +154,13 @@ describe('<ModelDialog />', () => {
expect(output).not.toContain(DEFAULT_GEMINI_MODEL);
expect(output).not.toContain(PREVIEW_GEMINI_MODEL);
// Verify order: Flash Preview -> Flash Lite Preview -> Flash -> Flash Lite
// Verify order: Flash Preview -> Flash Lite (Preview/Default) -> Flash
const flashPreviewIdx = output.indexOf(PREVIEW_GEMINI_FLASH_MODEL);
const flashLitePreviewIdx = output.indexOf(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
);
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
const flashLiteIdx = output.indexOf(DEFAULT_GEMINI_FLASH_LITE_MODEL);
const flashIdx = output.indexOf(DEFAULT_GEMINI_FLASH_MODEL);
expect(flashPreviewIdx).toBeLessThan(flashLitePreviewIdx);
expect(flashLitePreviewIdx).toBeLessThan(flashIdx);
expect(flashIdx).toBeLessThan(flashLiteIdx);
expect(flashPreviewIdx).toBeLessThan(flashLiteIdx);
expect(flashLiteIdx).toBeLessThan(flashIdx);
expect(output).not.toContain('Auto');
unmount();
@@ -453,7 +446,7 @@ describe('<ModelDialog />', () => {
unmount();
});
it('shows Flash Lite Preview model regardless of tier when flag is enabled', async () => {
it('does not show Flash Lite Preview model when it is retired (none) even if flag is enabled', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
@@ -472,7 +465,8 @@ describe('<ModelDialog />', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
expect(output).not.toContain(PREVIEW_GEMINI_FLASH_LITE_MODEL);
expect(output).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
unmount();
});
});
+29 -19
View File
@@ -13,7 +13,7 @@ import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -67,8 +67,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel() ?? false;
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
const useGemini3_5Flash = config?.hasGemini35FlashGAAccess?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
@@ -94,9 +93,9 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
];
].filter((m) => m !== 'none');
if (manualModels.includes(preferredModel)) {
return preferredModel;
}
@@ -131,7 +130,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
.getModelConfigService()
.getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useGemini3_5Flash,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
@@ -165,6 +164,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
useGemini3_5Flash,
),
key: GEMINI_MODEL_ALIAS_AUTO,
},
@@ -184,7 +184,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
shouldShowPreviewModels,
manualModelSelected,
useGemini31,
useGemini31FlashLite,
useGemini3_5Flash,
useCustomToolModel,
hasAccessToProModel,
]);
@@ -199,7 +199,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
.getModelConfigService()
.getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useGemini3_5Flash,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
@@ -223,16 +223,16 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
title: getDisplayString(DEFAULT_GEMINI_MODEL),
key: DEFAULT_GEMINI_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
key: DEFAULT_GEMINI_FLASH_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
key: DEFAULT_GEMINI_FLASH_LITE_MODEL,
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
key: DEFAULT_GEMINI_FLASH_MODEL,
},
];
if (showGemmaModels) {
@@ -272,11 +272,11 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (useGemini31FlashLite) {
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
key: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
value: PREVIEW_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL),
key: PREVIEW_GEMINI_FLASH_LITE_MODEL,
});
}
@@ -292,13 +292,23 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [
shouldShowPreviewModels,
useGemini31,
useGemini31FlashLite,
useGemini3_5Flash,
useCustomToolModel,
hasAccessToProModel,
config,
]);
const options = view === 'main' ? mainOptions : manualOptions;
const options = useMemo(() => {
const rawOptions = view === 'main' ? mainOptions : manualOptions;
const seen = new Set<string>();
return rawOptions.filter((option) => {
if (seen.has(option.value)) {
return false;
}
seen.add(option.value);
return true;
});
}, [view, mainOptions, manualOptions]);
// Calculate the initial index based on the preferred model.
const initialIndex = useMemo(() => {
@@ -353,6 +353,49 @@ describe('<ModelStatsDisplay />', () => {
unmount();
});
it('should resolve gemini-3-flash to gemini-3.5-flash via getDisplayString', async () => {
const { lastFrame, unmount } = await renderWithMockedStats({
models: {
'gemini-3-flash': {
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
tokens: {
input: 5,
prompt: 10,
candidates: 20,
total: 30,
cached: 5,
thoughts: 2,
tool: 1,
},
roles: {},
},
},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 0,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
[ToolCallDecision.AUTO_ACCEPT]: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
});
const output = lastFrame();
expect(output).toContain('gemini-3.5-flash');
expect(output).not.toContain('gemini-3-flash');
expect(output).toMatchSnapshot();
unmount();
});
it('should handle models with long names (gemini-3-*-preview) without layout breaking', async () => {
const { lastFrame, unmount } = await renderWithMockedStats(
{
@@ -299,7 +299,7 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
},
...modelNames.map((name) => ({
key: name,
header: name,
header: getDisplayString(name),
flexGrow: 1,
renderCell: (row: StatRowData) => {
// Don't render anything for section headers in model columns
@@ -131,6 +131,33 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});
it('resolves gemini-3-flash to gemini-3.5-flash in the model usage table', async () => {
const metrics = createTestMetrics({
models: {
'gemini-3-flash': {
api: { totalRequests: 5, totalErrors: 0, totalLatencyMs: 3000 },
tokens: {
input: 1000,
prompt: 2000,
candidates: 3000,
total: 5000,
cached: 500,
thoughts: 100,
tool: 50,
},
roles: {},
},
},
});
const { lastFrame } = await renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('gemini-3.5-flash');
expect(output).not.toContain('gemini-3-flash\u0020'); // Avoid matching parts of substrings if not intended
expect(output).toMatchSnapshot();
});
it('renders role breakdown correctly under models', async () => {
const metrics = createTestMetrics({
models: {
@@ -24,7 +24,7 @@ import {
import { computeSessionStats } from '../utils/computeStats.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { QuotaStats } from '../types.js';
import { LlmRole } from '@google/gemini-cli-core';
import { LlmRole, getDisplayString } from '@google/gemini-cli-core';
// A more flexible and powerful StatRow component
interface StatRowProps {
@@ -101,7 +101,7 @@ const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
Object.entries(models).forEach(([name, metrics]) => {
rows.push({
name,
displayName: name,
displayName: getDisplayString(name),
requests: metrics.api.totalRequests,
cachedTokens: metrics.tokens.cached.toLocaleString(),
inputTokens: metrics.tokens.prompt.toLocaleString(),
@@ -60,12 +60,6 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 2`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello 👍 world
@@ -215,3 +215,26 @@ exports[`<ModelStatsDisplay /> > should render "no API calls" message when there
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<ModelStatsDisplay /> > should resolve gemini-3-flash to gemini-3.5-flash via getDisplayString 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-3.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 1 │
│ Errors 0 (0.0%) │
│ Avg Latency 100ms │
│ Tokens │
│ Total 30 │
│ ↳ Input 5 │
│ ↳ Cache Reads 5 (50.0%) │
│ ↳ Thoughts 2 │
│ ↳ Tool 1 │
│ ↳ Output 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -292,3 +292,30 @@ exports[`<StatsDisplay /> > renders role breakdown correctly under models 1`] =
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<StatsDisplay /> > resolves gemini-3-flash to gemini-3.5-flash in the model usage table 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 3.0s │
│ » API Time: 3.0s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Use /model to view model quota information │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ gemini-3.5-flash 5 2,000 500 3,000 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -9,6 +9,17 @@ import { renderHook } from '../../../test-utils/render.js';
import { useTextBuffer } from './text-buffer.js';
import { parseInputForHighlighting } from '../../utils/highlight.js';
vi.mock('../../contexts/SettingsContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../contexts/SettingsContext.js')>();
return {
...actual,
useSettings: () => ({
merged: { general: { openEditorInNewWindow: false } },
}),
};
});
describe('text-buffer performance', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -44,6 +44,17 @@ import { cpLen } from '../../utils/textUtils.js';
import { type Key } from '../../hooks/useKeypress.js';
import { escapePath } from '@google/gemini-cli-core';
vi.mock('../../contexts/SettingsContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../contexts/SettingsContext.js')>();
return {
...actual,
useSettings: () => ({
merged: { general: { openEditorInNewWindow: false } },
}),
};
});
const defaultVisualLayout: VisualLayout = {
visualLines: [''],
logicalToVisualMap: [[[0, 0]]],
@@ -13,6 +13,7 @@ import { LRUCache } from 'mnemonist';
import {
coreEvents,
debugLogger,
getErrorMessage,
unescapePath,
type EditorType,
} from '@google/gemini-cli-core';
@@ -30,6 +31,7 @@ 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 } from '../../utils/editorUtils.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5;
@@ -2840,6 +2842,7 @@ export function useTextBuffer({
singleLine = false,
getPreferredEditor,
}: UseTextBufferProps): TextBuffer {
const settings = useSettings();
const keyMatchers = useKeyMatchers();
const initialState = useMemo((): TextBufferState => {
const lines = initialText.split('\n');
@@ -3325,6 +3328,7 @@ export function useTextBuffer({
stdin,
setRawMode,
getPreferredEditor?.(),
settings.merged.general.openEditorInNewWindow,
);
let newText = fs.readFileSync(filePath, 'utf8');
@@ -3342,11 +3346,7 @@ export function useTextBuffer({
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
} catch (err) {
coreEvents.emitFeedback(
'error',
'[useTextBuffer] external editor error',
err,
);
coreEvents.emitFeedback('error', getErrorMessage(err), err);
} finally {
try {
fs.unlinkSync(filePath);
@@ -3359,7 +3359,14 @@ export function useTextBuffer({
/* ignore */
}
}
}, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
}, [
text,
pastedContent,
stdin,
setRawMode,
getPreferredEditor,
settings.merged.general.openEditorInNewWindow,
]);
const handleInput = useCallback(
(key: Key): boolean => {
@@ -134,4 +134,5 @@ export const WITTY_LOADING_PHRASES = [
'Constructing additional pylons',
'New line? Thats Ctrl+J.',
'Releasing the HypnoDrones',
'Pushing the button, Frank.',
];
@@ -77,6 +77,12 @@ describe('handleAtCommand', () => {
unsubscribe: vi.fn(),
} as unknown as core.MessageBus;
const mockWorkspaceContext = {
isPathWithinWorkspace: (p: string) =>
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
getDirectories: () => [testRootDir],
};
mockConfig = {
getToolRegistry,
getTargetDir: () => testRootDir,
@@ -91,11 +97,7 @@ describe('handleAtCommand', () => {
}),
getFileSystemService: () => new StandardFileSystemService(),
getEnableRecursiveFileSearch: vi.fn(() => true),
getWorkspaceContext: () => ({
isPathWithinWorkspace: (p: string) =>
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
getDirectories: () => [testRootDir],
}),
getWorkspaceContext: () => mockWorkspaceContext,
getMemoryContextManager: () => undefined,
storage: {
getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'),
@@ -106,7 +108,8 @@ describe('handleAtCommand', () => {
}
const workspaceContext = this.getWorkspaceContext();
if (workspaceContext.isPathWithinWorkspace(absolutePath)) {
const directories = workspaceContext.getDirectories();
if (directories.some((dir) => absolutePath.startsWith(dir))) {
return true;
}
@@ -1462,31 +1465,126 @@ describe('handleAtCommand', () => {
);
});
it('should include agent nudge when agents are found', async () => {
const agentName = 'my-agent';
const otherAgent = 'other-agent';
it('should resolve files in multiple workspace directories', async () => {
const secondRootDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'second-root-'),
);
try {
const fileContent = 'Second root content';
const filePath = path.join(secondRootDir, 'second-file.txt');
await fsPromises.writeFile(filePath, fileContent);
// Mock getAgentRegistry on the config
mockConfig.getAgentRegistry = vi.fn().mockReturnValue({
getDefinition: (name: string) =>
name === agentName || name === otherAgent ? { name } : undefined,
vi.spyOn(
mockConfig.getWorkspaceContext(),
'getDirectories',
).mockReturnValue([testRootDir, secondRootDir]);
const query = '@second-file.txt';
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 700,
signal: abortController.signal,
});
expect(result.processedQuery).toContainEqual(
expect.objectContaining({ text: fileContent }),
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
expect.stringContaining(`resolved to file: ${filePath}`),
);
} finally {
await fsPromises.rm(secondRootDir, { recursive: true, force: true });
}
});
it('should attempt glob fallback if direct resolution is unauthorized', async () => {
const fileContent = 'Globbed content';
const filePath = await createTestFile(
path.join(testRootDir, 'secret', 'file.txt'),
fileContent,
);
// Mock validatePathAccess to deny direct access but allow it via glob (just for test purposes)
vi.spyOn(mockConfig, 'validatePathAccess').mockImplementation((p) => {
if (p.includes('secret') && !p.includes('file.txt'))
return 'Unauthorized';
// Let's say the direct path 'secret/file.txt' is unauthorized
if (p === filePath) return 'Access Denied';
return null;
});
const query = `@${agentName} @${otherAgent}`;
const query = '@secret/file.txt';
await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 701,
signal: abortController.signal,
});
// In this case, resolveAtCommandPath returns status: 'unauthorized'.
// resolveFilePaths should then try glob fallback.
expect(mockOnDebugMessage).toHaveBeenCalledWith(
expect.stringContaining('not found directly, attempting glob search.'),
);
});
it('should skip malformed paths (the original crash scenario)', async () => {
// We use a quoted path so the parser treats the whole thing as one @path token
const malformedPath =
'"FAIL tests/int/my.test.ts ... AssertionError: expected true to be false"';
const query = `@${malformedPath}`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 600,
messageId: 702,
signal: abortController.signal,
});
const expectedNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentName}, ${otherAgent}. Please use the following tool(s) to delegate the task: '${agentName}', '${otherAgent}'.\n</system_note>\n`;
// Malformed path should be skipped and original query part preserved as text
expect(result.processedQuery).toEqual([{ text: query }]);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
expect.stringContaining(
'Identified invalid path fragment, attempting to extract path',
),
);
});
it('should recover a buried path from a malformed fragment during handleAtCommand', async () => {
const buriedFile = 'src/recovered.ts';
await createTestFile(
path.join(testRootDir, buriedFile),
'Recovered content',
);
const malformedFragment = `"FAIL ${buriedFile}:10:5 (AssertionError)"`;
const query = `@${malformedFragment}`;
const result = await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 703,
signal: abortController.signal,
});
// It should extract src/recovered.ts and attach its content
expect(result.processedQuery).toContainEqual(
expect.objectContaining({ text: expectedNudge }),
expect.objectContaining({ text: 'Recovered content' }),
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
expect.stringContaining(
'Identified invalid path fragment, attempting to extract path',
),
);
});
});
+89 -85
View File
@@ -4,14 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { PartListUnion, PartUnion } from '@google/genai';
import type { AnyToolInvocation, Config } from '@google/gemini-cli-core';
import {
debugLogger,
getErrorMessage,
isNodeError,
unescapePath,
resolveToRealPath,
fileExists,
@@ -19,6 +17,7 @@ import {
REFERENCE_CONTENT_START,
REFERENCE_CONTENT_END,
CoreToolCallStatus,
resolveAtCommandPath,
} from '@google/gemini-cli-core';
import { Buffer } from 'node:buffer';
import type {
@@ -271,102 +270,100 @@ async function resolveFilePaths(
continue;
}
for (const dir of config.getWorkspaceContext().getDirectories()) {
try {
const absolutePath = path.resolve(dir, pathName);
const stats = await fs.stat(absolutePath);
const result = await resolveAtCommandPath(pathName, config, onDebugMessage);
const relativePath = path.isAbsolute(pathName)
? path.relative(dir, absolutePath)
: pathName;
if (result.status === 'resolved') {
const { absolutePath, relativePath, stats } = result.resolved;
if (stats.isDirectory()) {
const pathSpec = path.join(relativePath, '**');
resolvedFiles.push({
part,
pathSpec,
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
absolutePath,
});
onDebugMessage(
`Path ${pathName} resolved to directory, using glob: ${pathSpec}`,
);
} else {
resolvedFiles.push({
part,
pathSpec: relativePath,
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
absolutePath,
});
onDebugMessage(
`Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`,
);
}
} else if (
result.status === 'not_found' ||
result.status === 'unauthorized'
) {
// If direct resolution fails, we attempt glob search if enabled.
// We also allow glob fallback for "unauthorized" results from resolveAtCommandPath,
// as they might represent a relative path that matched an unauthorized file in one directory
// but might have a valid match (via glob) in another.
if (config.getEnableRecursiveFileSearch() && globTool) {
onDebugMessage(
`Path ${pathName} not found directly, attempting glob search.`,
);
if (stats.isDirectory()) {
const pathSpec = path.join(relativePath, '**');
resolvedFiles.push({
part,
pathSpec,
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
absolutePath,
});
onDebugMessage(
`Path ${pathName} resolved to directory, using glob: ${pathSpec}`,
);
} else {
resolvedFiles.push({
part,
pathSpec: relativePath,
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
absolutePath,
});
onDebugMessage(
`Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`,
);
}
break;
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (config.getEnableRecursiveFileSearch() && globTool) {
onDebugMessage(
`Path ${pathName} not found directly, attempting glob search.`,
for (const dir of config.getWorkspaceContext().getDirectories()) {
try {
const globResult = await globTool.buildAndExecute(
{
pattern: `**/*${pathName}*`,
path: dir,
},
signal,
);
try {
const globResult = await globTool.buildAndExecute(
{
pattern: `**/*${pathName}*`,
path: dir,
},
signal,
);
if (
globResult.llmContent &&
typeof globResult.llmContent === 'string' &&
!globResult.llmContent.startsWith('No files found') &&
!globResult.llmContent.startsWith('Error:')
) {
const lines = globResult.llmContent.split('\n');
if (lines.length > 1 && lines[1]) {
const firstMatchAbsolute = lines[1].trim();
const pathSpec = path.relative(dir, firstMatchAbsolute);
resolvedFiles.push({
part,
pathSpec,
displayLabel: path.isAbsolute(pathName)
? pathSpec
: pathName,
});
onDebugMessage(
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${pathSpec}`,
);
break;
} else {
onDebugMessage(
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
);
if (
globResult.llmContent &&
typeof globResult.llmContent === 'string' &&
!globResult.llmContent.startsWith('No files found') &&
!globResult.llmContent.startsWith('Error:')
) {
const lines = globResult.llmContent.split('\n');
if (lines.length > 1 && lines[1]) {
const rawMatch = lines[1].trim();
let firstMatchAbsolute: string;
try {
firstMatchAbsolute = resolveToRealPath(rawMatch);
} catch {
firstMatchAbsolute = rawMatch;
}
const pathSpec = path.relative(dir, firstMatchAbsolute);
resolvedFiles.push({
part,
pathSpec,
displayLabel: path.isAbsolute(pathName) ? pathSpec : pathName,
absolutePath: firstMatchAbsolute,
});
onDebugMessage(
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${pathSpec}`,
);
break;
} else {
onDebugMessage(
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
);
}
} catch (globError) {
debugLogger.warn(
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
);
} else {
onDebugMessage(
`Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
);
}
} else {
onDebugMessage(
`Glob tool not found. Path ${pathName} will be skipped.`,
} catch (globError) {
debugLogger.warn(
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
);
}
} else {
debugLogger.warn(
`Error stating path ${pathName}: ${getErrorMessage(error)}`,
);
}
} else {
if (!config.getEnableRecursiveFileSearch() || !globTool) {
onDebugMessage(
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
`Glob tool not found. Path ${pathName} will be skipped.`,
);
}
}
@@ -524,7 +521,14 @@ async function readLocalFiles(
config.getMessageBus(),
);
const pathSpecsToRead = resolvedFiles.map((rf) => rf.pathSpec);
const pathSpecsToRead = resolvedFiles.map((rf) => {
if (rf.absolutePath) {
return rf.pathSpec.endsWith('**')
? path.join(rf.absolutePath, '**')
: rf.absolutePath;
}
return rf.pathSpec;
});
const fileLabelsForDisplay = resolvedFiles.map((rf) => rf.displayLabel);
const respectFileIgnore = config.getFileFilteringOptions();
+93 -2
View File
@@ -10,12 +10,14 @@ import {
expect,
vi,
beforeEach,
afterEach,
type MockedFunction,
} from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useBanner, _clearSessionBannersForTest } from './useBanner.js';
import { persistentState } from '../../utils/persistentState.js';
import crypto from 'node:crypto';
import chalk from 'chalk';
vi.mock('../../utils/persistentState.js', () => ({
persistentState: {
@@ -77,10 +79,26 @@ describe('useBanner', () => {
.update(defaultBannerData.defaultText)
.digest('hex')]: 5,
});
});
const { result } = await renderHook(() => useBanner(defaultBannerData));
it('should not hide banner if show count exceeds max limit (Legacy format) if it contains an Antigravity announcement', async () => {
const antigravityBannerData = {
defaultText: 'Antigravity is coming to town!',
warningText: '',
};
expect(result.current.bannerText).toBe('');
mockedPersistentStateGet.mockReturnValue({
[crypto
.createHash('sha256')
.update(antigravityBannerData.defaultText)
.digest('hex')]: 5,
});
const { result } = await renderHook(() => useBanner(antigravityBannerData));
expect(result.current.bannerText).toContain(
'Antigravity is coming to town!',
);
});
it('should increment the persistent count when banner is shown', async () => {
@@ -123,4 +141,77 @@ describe('useBanner', () => {
expect(result.current.bannerText).toBe('Line1\nLine2');
});
describe('Antigravity installation commands', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
vi.unstubAllEnvs();
});
it('should append macOS & Linux install command when on darwin', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe(
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.sh | bash')}"`,
);
});
it('should append macOS & Linux install command when on linux', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe(
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.sh | bash')}"`,
);
});
it('should append Windows PowerShell install command when on win32 and PSModulePath is set', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', 'C:\\some\\path');
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe(
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('irm https://antigravity.google/cli/install.ps1 | iex')}"`,
);
});
it('should append Windows CMD install command when on win32 and PSModulePath is not set', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', '');
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe(
`Welcome to Antigravity!\n \nTo install run "${chalk.bold('curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd')}"`,
);
});
it('should not append install command if banner text does not contain Antigravity', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const data = { defaultText: 'Regular Banner', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe('Regular Banner');
});
it('should not append install command if process.platform is an unsupported platform', async () => {
Object.defineProperty(process, 'platform', { value: 'freebsd' });
const data = { defaultText: 'Welcome to Antigravity!', warningText: '' };
const { result } = await renderHook(() => useBanner(data));
expect(result.current.bannerText).toBe('Welcome to Antigravity!');
});
});
});
+13 -2
View File
@@ -7,6 +7,8 @@
import { useState, useEffect } from 'react';
import { persistentState } from '../../utils/persistentState.js';
import crypto from 'node:crypto';
import chalk from 'chalk';
import { getAntigravityInstallInfo } from '../utils/antigravityUtils.js';
const DEFAULT_MAX_BANNER_SHOWN_COUNT = 5;
@@ -41,10 +43,19 @@ export function useBanner(bannerData: BannerData) {
const currentBannerCount = bannerCounts[hashedText] || 0;
const showBanner =
activeText !== '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
activeText !== '' &&
(currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT ||
activeText.includes('Antigravity'));
const rawBannerText = showBanner ? activeText : '';
const bannerText = rawBannerText.replace(/\\n/g, '\n');
let bannerText = rawBannerText.replace(/\\n/g, '\n');
if (showBanner && activeText.includes('Antigravity')) {
const info = getAntigravityInstallInfo();
if (info) {
bannerText += `\n \nTo install run "${chalk.bold(info.installCmd)}"`;
}
}
useEffect(() => {
if (showBanner && activeText) {
@@ -83,8 +83,6 @@ describe('useSuspend', () => {
it('cleans terminal state on suspend and restores/repaints on resume in alternate screen mode', async () => {
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const enableSupportedModes =
terminalCapabilityManager.enableSupportedModes as unknown as Mock;
@@ -92,8 +90,6 @@ describe('useSuspend', () => {
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: true,
}),
);
@@ -131,8 +127,6 @@ describe('useSuspend', () => {
expect(enableSupportedModes).toHaveBeenCalledTimes(1);
expect(enableMouseEvents).toHaveBeenCalledTimes(1);
expect(setRawMode).toHaveBeenCalledWith(true);
expect(refreshStatic).toHaveBeenCalledTimes(1);
expect(setForceRerenderKey).toHaveBeenCalledTimes(1);
unmount();
});
@@ -140,15 +134,11 @@ describe('useSuspend', () => {
it('does not toggle alternate screen or mouse restore when alternate screen mode is disabled', async () => {
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const { result, unmount } = await renderHook(() =>
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: false,
}),
);
@@ -174,15 +164,11 @@ describe('useSuspend', () => {
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const { result, unmount } = await renderHook(() =>
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: true,
}),
);
+2 -21
View File
@@ -26,16 +26,12 @@ import { Command } from '../key/keyBindings.js';
interface UseSuspendProps {
handleWarning: (message: string) => void;
setRawMode: (mode: boolean) => void;
refreshStatic: () => void;
setForceRerenderKey: (updater: (prev: number) => number) => void;
shouldUseAlternateScreen: boolean;
}
export function useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
}: UseSuspendProps) {
const [ctrlZPressCount, setCtrlZPressCount] = useState(0);
@@ -108,16 +104,8 @@ export function useSuspend({
enableMouseEvents();
}
// Force Ink to do a complete repaint by:
// 1. Emitting a resize event (tricks Ink into full redraw)
// 2. Remounting components via state changes
// Force Ink to do a complete repaint without remounting the app.
process.stdout.emit('resize');
// Give a tick for resize to process, then trigger remount
setImmediate(() => {
refreshStatic();
setForceRerenderKey((prev) => prev + 1);
});
} finally {
if (onResumeHandlerRef.current === onResume) {
onResumeHandlerRef.current = null;
@@ -142,14 +130,7 @@ export function useSuspend({
ctrlZTimerRef.current = null;
}, WARNING_PROMPT_DURATION_MS);
}
}, [
ctrlZPressCount,
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
]);
}, [ctrlZPressCount, handleWarning, setRawMode, shouldUseAlternateScreen]);
const handleSuspend = useCallback(() => {
setCtrlZPressCount((prev) => prev + 1);
@@ -81,4 +81,24 @@ describe('useVim passthrough', () => {
expect(handled).toBe(false);
});
it.each(['H', 'M', 'Q', 'm'])(
'should ignore unmapped printable key %s in NORMAL mode',
async (sequence) => {
mockVimContext.vimMode = 'NORMAL';
const { result } = await renderHook(() =>
useVim(mockBuffer as TextBuffer),
);
let handled = false;
act(() => {
handled = result.current.handleInput(
createKey({ name: sequence, sequence, insertable: true }),
);
});
expect(handled).toBe(true);
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
},
);
});
+8 -2
View File
@@ -1486,8 +1486,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Unknown command, clear count and pending states
dispatch({ type: 'CLEAR_PENDING_STATES' });
// Ignore any Insertable key in Normal Mode
if (normalizedKey.insertable) {
// Ignore unmapped Insertable keys in Normal Mode, but let
// modifier-key chords (ctrl/alt/cmd) fall through to other handlers.
if (
normalizedKey.insertable &&
!normalizedKey.ctrl &&
!normalizedKey.alt &&
!normalizedKey.cmd
) {
return true;
}
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getAntigravityInstallInfo } from './antigravityUtils.js';
describe('antigravityUtils', () => {
const originalPlatform = process.platform;
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
vi.unstubAllEnvs();
});
it('should return macOS installation info on darwin platform', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
const info = getAntigravityInstallInfo();
expect(info).toEqual({
platformName: 'macOS',
installCmd: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
});
});
it('should return Linux installation info on linux platform', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
const info = getAntigravityInstallInfo();
expect(info).toEqual({
platformName: 'Linux',
installCmd: 'curl -fsSL https://antigravity.google/cli/install.sh | bash',
});
});
it('should return Windows PowerShell installation info on win32 when PSModulePath is set', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', 'C:\\some\\path');
const info = getAntigravityInstallInfo();
expect(info).toEqual({
platformName: 'Windows (PowerShell)',
installCmd: 'irm https://antigravity.google/cli/install.ps1 | iex',
});
});
it('should return Windows CMD installation info on win32 when PSModulePath is not set', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
vi.stubEnv('PSModulePath', '');
const info = getAntigravityInstallInfo();
expect(info).toEqual({
platformName: 'Windows (Command Prompt)',
installCmd:
'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd',
});
});
it('should return null on unsupported platform', () => {
Object.defineProperty(process, 'platform', { value: 'freebsd' });
const info = getAntigravityInstallInfo();
expect(info).toBeNull();
});
});
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
const ANTIGRAVITY_SH_INSTALL =
'curl -fsSL https://antigravity.google/cli/install.sh | bash';
export interface AntigravityInstallInfo {
platformName: string;
installCmd: string;
}
/**
* Gets the platform-specific installation details for the Antigravity CLI.
* Returns null if the current platform is unsupported.
*/
export function getAntigravityInstallInfo(): AntigravityInstallInfo | null {
if (process.platform === 'win32') {
if (process.env['PSModulePath']) {
return {
platformName: 'Windows (PowerShell)',
installCmd: 'irm https://antigravity.google/cli/install.ps1 | iex',
};
} else {
return {
platformName: 'Windows (Command Prompt)',
installCmd:
'curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd',
};
}
} else if (process.platform === 'darwin') {
return {
platformName: 'macOS',
installCmd: ANTIGRAVITY_SH_INSTALL,
};
} else if (process.platform === 'linux') {
return {
platformName: 'Linux',
installCmd: ANTIGRAVITY_SH_INSTALL,
};
}
return null;
}
+112 -53
View File
@@ -7,14 +7,33 @@
import { spawn, spawnSync } from 'node:child_process';
import type { ReadStream } from 'node:tty';
import {
coreEvents,
ALL_EDITORS,
CoreEvent,
coreEvents,
type EditorType,
getEditorCommand,
getEditorExtraArgs,
getEditorWaitFlag,
isGuiEditor,
isTerminalEditor,
isValidEditorType,
resolveEditorTypeFromCommand,
} from '@google/gemini-cli-core';
/**
* Command name substrings used to guess whether an unknown $VISUAL/$EDITOR
* value is a GUI editor. This is a fallback for editors not in the registry;
* registered editors are detected via resolveEditorTypeFromCommand instead.
*/
const HEURISTIC_GUI_COMMANDS = [
'code',
'cursor',
'subl',
'zed',
'atom',
'agy',
] as const;
/**
* 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.
@@ -23,36 +42,65 @@ import {
* @param stdin The stdin stream from Ink/Node
* @param setRawMode Function to toggle raw mode
* @param preferredEditorType The user's preferred editor from config
* @param openInNewWindow Whether to open VS Code-family editors in a new window
*/
export async function openFileInEditor(
filePath: string,
stdin: ReadStream | null | undefined,
setRawMode: ((mode: boolean) => void) | undefined,
preferredEditorType?: EditorType,
openInNewWindow?: boolean,
): Promise<void> {
let command: string | undefined = undefined;
const args = [filePath];
// Extra args that come before the file path (e.g. -nw for emacsclient)
const extraArgs: string[] = [];
if (preferredEditorType) {
if (!isValidEditorType(preferredEditorType)) {
coreEvents.emitFeedback(
'error',
`Editor '${preferredEditorType}' is not a recognized editor identifier. ` +
`Supported editors: ${ALL_EDITORS.join(', ')}. ` +
`Use /editor to select one, or set the $VISUAL or $EDITOR environment variable.`,
);
return;
}
command = getEditorCommand(preferredEditorType);
if (isGuiEditor(preferredEditorType)) {
args.unshift('--wait');
args.unshift(getEditorWaitFlag(preferredEditorType));
}
extraArgs.push(
...getEditorExtraArgs(preferredEditorType, {
newWindow: openInNewWindow,
}),
);
}
if (!command) {
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');
const envCommand = process.env['VISUAL'] ?? process.env['EDITOR'];
if (envCommand) {
command = envCommand;
const [envExecutable = ''] = envCommand.split(' ');
const resolvedType = resolveEditorTypeFromCommand(envExecutable);
if (resolvedType) {
if (
isGuiEditor(resolvedType) &&
!envCommand.includes('--wait') &&
!envCommand.includes('-w')
) {
args.unshift(getEditorWaitFlag(resolvedType));
}
extraArgs.push(
...getEditorExtraArgs(resolvedType, { newWindow: openInNewWindow }),
);
} else {
// Heuristic fallback for commands not in the registry
const lower = envCommand.toLowerCase();
const isGui = HEURISTIC_GUI_COMMANDS.some((g) => lower.includes(g));
if (isGui && !lower.includes('--wait') && !lower.includes('-w')) {
args.unshift(lower.includes('subl') ? '-w' : '--wait');
}
}
}
}
@@ -66,7 +114,16 @@ export async function openFileInEditor(
// Determine if we should use sync or async based on the command/editor type.
// If we have a preferredEditorType, we can check if it's a terminal editor.
// Otherwise, we guess based on the command name.
const terminalEditors = ['vi', 'vim', 'nvim', 'emacs', 'hx', 'nano'];
const terminalEditors = [
'vi',
'vim',
'nvim',
'emacs',
'emacsclient',
'hx',
'nano',
'micro',
];
const isTerminal = preferredEditorType
? isTerminalEditor(preferredEditorType)
: terminalEditors.some((te) => executable.toLowerCase().includes(te));
@@ -86,58 +143,60 @@ export async function openFileInEditor(
try {
if (isTerminal) {
const result = spawnSync(executable, [...initialArgs, ...args], {
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.error) {
coreEvents.emitFeedback(
'error',
'[editorUtils] external terminal editor error',
result.error,
);
throw result.error;
}
if (typeof result.status === 'number' && result.status !== 0) {
const err = new Error(
`External editor exited with status ${result.status}`,
);
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor error',
err,
);
throw err;
}
} else {
await new Promise<void>((resolve, reject) => {
const child = spawn(executable, [...initialArgs, ...args], {
const result = spawnSync(
executable,
[...initialArgs, ...extraArgs, ...args],
{
stdio: 'inherit',
shell: process.platform === 'win32',
});
},
);
if (result.error) {
const spawnErr = result.error as NodeJS.ErrnoException;
coreEvents.emitFeedback(
'error',
spawnErr.code === 'ENOENT'
? `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`
: (spawnErr.message ?? String(spawnErr)),
);
return;
}
if (typeof result.status === 'number' && result.status !== 0) {
coreEvents.emitFeedback(
'error',
`External editor exited with status ${result.status}`,
);
return;
}
} else {
await new Promise<void>((resolve) => {
const child = spawn(
executable,
[...initialArgs, ...extraArgs, ...args],
{
stdio: 'inherit',
shell: process.platform === 'win32',
},
);
child.on('error', (err) => {
const spawnErr = err as NodeJS.ErrnoException;
resolve();
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor spawn error',
err,
spawnErr.code === 'ENOENT'
? `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`
: (spawnErr.message ?? String(spawnErr)),
);
reject(err);
});
child.on('close', (status) => {
resolve();
if (typeof status === 'number' && status !== 0) {
const err = new Error(
`External editor exited with status ${status}`,
);
coreEvents.emitFeedback(
'error',
'[editorUtils] external editor error',
err,
`External editor exited with status ${status}`,
);
reject(err);
} else {
resolve();
}
});
});
@@ -352,6 +352,30 @@ describe('getInstallationInfo', () => {
expect(infoDisabled.updateMessage).toContain('Please run npm install');
});
it('should detect Volta installation (Unix-style)', () => {
const voltaPath =
'/Users/test/.volta/tools/image/node/20.0.0/lib/node_modules/@google/gemini-cli/dist/index.js';
process.argv[1] = voltaPath;
mockedRealPathSync.mockReturnValue(voltaPath);
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.VOLTA);
expect(info.updateCommand).toBe('volta install @google/gemini-cli@latest');
});
it('should detect Volta installation (Windows-style)', () => {
const voltaPath =
'C:\\Users\\test\\AppData\\Local\\Volta\\tools\\image\\node\\20.0.0\\node_modules\\@google/gemini-cli\\dist\\index.js';
process.argv[1] = voltaPath;
mockedRealPathSync.mockReturnValue(voltaPath);
const info = getInstallationInfo(projectRoot, true);
expect(info.packageManager).toBe(PackageManager.VOLTA);
expect(info.updateCommand).toBe('volta install @google/gemini-cli@latest');
});
it('should NOT detect Homebrew if gemini-cli is installed in brew but running from npm location', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
+17 -1
View File
@@ -22,6 +22,7 @@ export enum PackageManager {
HOMEBREW = 'homebrew',
NPX = 'npx',
BINARY = 'binary',
VOLTA = 'volta',
UNKNOWN = 'unknown',
}
@@ -116,10 +117,25 @@ export function getInstallationInfo(
}
}
// Check for Volta
if (realPath.includes('/.volta/') || realPath.includes('/Volta/')) {
const updateCommand = 'volta install @google/gemini-cli@latest';
return {
packageManager: PackageManager.VOLTA,
isGlobal: true,
updateCommand,
updateMessage: isAutoUpdateEnabled
? 'Installed with Volta. Attempting to automatically update now...'
: `Please run ${updateCommand} to update`,
};
}
// Check for pnpm
if (
realPath.includes('/.pnpm/global') ||
realPath.includes('/.local/share/pnpm')
realPath.includes('/.local/share/pnpm') ||
realPath.includes('/Library/pnpm/global/') ||
realPath.includes('/AppData/Local/pnpm/global/')
) {
const updateCommand = 'pnpm add -g @google/gemini-cli@latest';
return {
+21
View File
@@ -46,6 +46,14 @@ import { relaunchAppInChildProcess, relaunchOnExitCode } from './relaunch.js';
describe('relaunchOnExitCode', () => {
let processExitSpy: MockInstance;
let stdinResumeSpy: MockInstance;
const originalPlatform = process.platform;
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
};
beforeEach(() => {
processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
@@ -60,6 +68,7 @@ describe('relaunchOnExitCode', () => {
afterEach(() => {
vi.unstubAllEnvs();
setPlatform(originalPlatform);
processExitSpy.mockRestore();
stdinResumeSpy.mockRestore();
});
@@ -92,6 +101,18 @@ describe('relaunchOnExitCode', () => {
expect(processExitSpy).toHaveBeenCalledWith(0);
});
it('should not relaunch on Android when RELAUNCH_EXIT_CODE is returned', async () => {
setPlatform('android');
const runner = vi.fn().mockResolvedValue(RELAUNCH_EXIT_CODE);
await expect(relaunchOnExitCode(runner)).rejects.toThrow(
'PROCESS_EXIT_CALLED',
);
expect(runner).toHaveBeenCalledTimes(1);
expect(processExitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
it('should handle runner errors', async () => {
const error = new Error('Runner failed');
const runner = vi.fn().mockRejectedValue(error);
+1 -1
View File
@@ -20,7 +20,7 @@ export async function relaunchOnExitCode(runner: () => Promise<number>) {
try {
const exitCode = await runner();
if (exitCode !== RELAUNCH_EXIT_CODE) {
if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) {
process.exit(exitCode);
}
} catch (error) {
@@ -1111,6 +1111,28 @@ describe('convertSessionToHistoryFormats', () => {
});
});
it('should filter out <session_context> from UI history', () => {
const messages: MessageRecord[] = [
{
id: '1',
timestamp: new Date().toISOString(),
type: 'user',
content:
'<session_context>\nThis is the Gemini CLI\n</session_context>',
},
{
id: '2',
timestamp: new Date().toISOString(),
type: 'user',
content: 'Real message',
},
];
const result = convertSessionToHistoryFormats(messages);
expect(result.uiHistory).toHaveLength(1);
expect(result.uiHistory[0].text).toBe('Real message');
});
it('should handle missing tool descriptions and displayNames', () => {
const messages: MessageRecord[] = [
{
+10 -1
View File
@@ -606,7 +606,16 @@ export function convertSessionToHistoryFormats(
const contentString = partListUnionToString(msg.content);
const uiText = displayContentString || contentString;
if (uiText.trim()) {
// Skip internal context messages in the UI history
const trimmedText = uiText.trim();
if (
trimmedText.startsWith('<session_context>') ||
trimmedText.startsWith('<hook_context>')
) {
continue;
}
if (trimmedText) {
let messageType: MessageType;
switch (msg.type) {
case 'user':
+21 -16
View File
@@ -5,7 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ChatRecordingService, type Config } from '@google/gemini-cli-core';
import { deleteStoredSession, type Config } from '@google/gemini-cli-core';
import { listSessions, deleteSession } from './sessions.js';
import { SessionSelector, type SessionInfo } from './sessionUtils.js';
@@ -14,7 +14,7 @@ const mocks = vi.hoisted(() => ({
writeToStderr: vi.fn(),
}));
// Mock the SessionSelector and ChatRecordingService
// Mock the SessionSelector and deleteStoredSession.
vi.mock('./sessionUtils.js', () => ({
SessionSelector: vi.fn(),
formatRelativeTime: vi.fn(() => 'some time ago'),
@@ -24,7 +24,7 @@ vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
ChatRecordingService: vi.fn(),
deleteStoredSession: vi.fn(),
generateSummary: vi.fn().mockResolvedValue(undefined),
writeToStdout: mocks.writeToStdout,
writeToStderr: mocks.writeToStderr,
@@ -347,7 +347,8 @@ describe('deleteSession', () => {
// Create mock methods
mockListSessions = vi.fn();
mockDeleteSession = vi.fn();
mockDeleteSession = vi.mocked(deleteStoredSession);
mockDeleteSession.mockReset();
// Mock SessionSelector constructor
vi.mocked(SessionSelector).mockImplementation(
@@ -356,14 +357,6 @@ describe('deleteSession', () => {
listSessions: mockListSessions,
}) as unknown as InstanceType<typeof SessionSelector>,
);
// Mock ChatRecordingService
vi.mocked(ChatRecordingService).mockImplementation(
() =>
({
deleteSession: mockDeleteSession,
}) as unknown as InstanceType<typeof ChatRecordingService>,
);
});
afterEach(() => {
@@ -411,7 +404,10 @@ describe('deleteSession', () => {
// Assert
expect(mockListSessions).toHaveBeenCalledOnce();
expect(mockDeleteSession).toHaveBeenCalledWith('session-file-123');
expect(mockDeleteSession).toHaveBeenCalledWith(
mockConfig,
'session-file-123',
);
expect(mocks.writeToStdout).toHaveBeenCalledWith(
'Deleted session 1: Test session (some time ago)',
);
@@ -458,7 +454,10 @@ describe('deleteSession', () => {
// Assert
expect(mockListSessions).toHaveBeenCalledOnce();
expect(mockDeleteSession).toHaveBeenCalledWith('session-file-2');
expect(mockDeleteSession).toHaveBeenCalledWith(
mockConfig,
'session-file-2',
);
expect(mocks.writeToStdout).toHaveBeenCalledWith(
'Deleted session 2: Second session (some time ago)',
);
@@ -641,7 +640,10 @@ describe('deleteSession', () => {
await deleteSession(mockConfig, '1');
// Assert
expect(mockDeleteSession).toHaveBeenCalledWith('session-file-1');
expect(mockDeleteSession).toHaveBeenCalledWith(
mockConfig,
'session-file-1',
);
expect(mocks.writeToStderr).toHaveBeenCalledWith(
'Failed to delete session: File deletion failed',
);
@@ -732,7 +734,10 @@ describe('deleteSession', () => {
await deleteSession(mockConfig, '1');
// Assert
expect(mockDeleteSession).toHaveBeenCalledWith('session-file-1');
expect(mockDeleteSession).toHaveBeenCalledWith(
mockConfig,
'session-file-1',
);
expect(mocks.writeToStdout).toHaveBeenCalledWith(
expect.stringContaining('Oldest session'),
);
+2 -4
View File
@@ -5,7 +5,7 @@
*/
import {
ChatRecordingService,
deleteStoredSession,
generateSummary,
writeToStderr,
writeToStdout,
@@ -95,9 +95,7 @@ export async function deleteSession(
}
try {
// Use ChatRecordingService to delete the session
const chatRecordingService = new ChatRecordingService(config);
await chatRecordingService.deleteSession(sessionToDelete.file);
await deleteStoredSession(config, sessionToDelete.file);
const time = formatRelativeTime(sessionToDelete.lastUpdated);
writeToStdout(
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.44.0-nightly.20260512.g022e8baef",
"version": "0.47.0-nightly.20260602.gcfcecebe8",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -54,7 +54,6 @@
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
"command-exists": "^1.2.9",
"diff": "^8.0.3",
+122
View File
@@ -12,6 +12,8 @@ import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import { LocalSessionInvocation } from './local-session-invocation.js';
import { RemoteSessionInvocation } from './remote-session-invocation.js';
import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js';
import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js';
import { AgentRegistry } from './registry.js';
@@ -19,6 +21,8 @@ import type { LocalAgentDefinition, RemoteAgentDefinition } from './types.js';
vi.mock('./local-invocation.js');
vi.mock('./remote-invocation.js');
vi.mock('./local-session-invocation.js');
vi.mock('./remote-session-invocation.js');
vi.mock('./browser/browserAgentInvocation.js');
describe('AgentTool', () => {
@@ -141,4 +145,122 @@ describe('AgentTool', () => {
'Invoke Browser Agent',
);
});
describe('agentSessionSubagentEnabled feature flag', () => {
it('should use LocalSessionInvocation when flag is enabled for local agent', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
true,
);
tool = new AgentTool(mockConfig, mockMessageBus);
const params = {
agent_name: 'TestLocalAgent',
prompt: 'Do something',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
await invocation.shouldConfirmExecute(new AbortController().signal);
expect(LocalSessionInvocation).toHaveBeenCalledWith(
testLocalDefinition,
mockConfig,
{ objective: 'Do something' },
mockMessageBus,
undefined,
);
expect(LocalSubagentInvocation).not.toHaveBeenCalled();
});
it('should use RemoteSessionInvocation when flag is enabled for remote agent', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
true,
);
tool = new AgentTool(mockConfig, mockMessageBus);
const params = {
agent_name: 'TestRemoteAgent',
prompt: 'Search something',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
await invocation.shouldConfirmExecute(new AbortController().signal);
expect(RemoteSessionInvocation).toHaveBeenCalledWith(
testRemoteDefinition,
mockConfig,
{ query: 'Search something' },
mockMessageBus,
undefined,
);
expect(RemoteAgentInvocation).not.toHaveBeenCalled();
});
it('should use legacy invocations when flag is disabled (default)', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
false,
);
tool = new AgentTool(mockConfig, mockMessageBus);
const localParams = {
agent_name: 'TestLocalAgent',
prompt: 'Do something',
};
const localInv = tool['createInvocation'](localParams, mockMessageBus);
await localInv.shouldConfirmExecute(new AbortController().signal);
expect(LocalSubagentInvocation).toHaveBeenCalled();
expect(LocalSessionInvocation).not.toHaveBeenCalled();
vi.clearAllMocks();
const remoteParams = {
agent_name: 'TestRemoteAgent',
prompt: 'Search',
};
const remoteInv = tool['createInvocation'](remoteParams, mockMessageBus);
await remoteInv.shouldConfirmExecute(new AbortController().signal);
expect(RemoteAgentInvocation).toHaveBeenCalled();
expect(RemoteSessionInvocation).not.toHaveBeenCalled();
});
it('should thread onAgentEvent to session invocations', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
true,
);
const onEvent = vi.fn();
tool = new AgentTool(mockConfig, mockMessageBus, onEvent);
const params = {
agent_name: 'TestLocalAgent',
prompt: 'Do something',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
await invocation.shouldConfirmExecute(new AbortController().signal);
expect(LocalSessionInvocation).toHaveBeenCalledWith(
testLocalDefinition,
mockConfig,
{ objective: 'Do something' },
mockMessageBus,
{ onAgentEvent: onEvent },
);
});
it('should always use BrowserAgentInvocation for browser agent regardless of flag', async () => {
vi.spyOn(mockConfig, 'isAgentSessionSubagentEnabled').mockReturnValue(
true,
);
tool = new AgentTool(mockConfig, mockMessageBus);
const params = {
agent_name: BROWSER_AGENT_NAME,
prompt: 'Open page',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
await invocation.shouldConfirmExecute(new AbortController().signal);
expect(BrowserAgentInvocation).toHaveBeenCalled();
expect(LocalSessionInvocation).not.toHaveBeenCalled();
expect(RemoteSessionInvocation).not.toHaveBeenCalled();
});
});
});
+29
View File
@@ -18,8 +18,11 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import { LocalSessionInvocation } from './local-session-invocation.js';
import { RemoteSessionInvocation } from './remote-session-invocation.js';
import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js';
import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js';
import type { AgentEvent } from '../agent/types.js';
import { formatUserHintsForModel } from '../utils/fastAckHelper.js';
import { isRecord } from '../utils/markdownUtils.js';
import { runInDevTraceSpan } from '../telemetry/trace.js';
@@ -46,6 +49,7 @@ export class AgentTool extends BaseDeclarativeTool<
constructor(
private readonly context: AgentLoopContext,
messageBus: MessageBus,
private readonly onAgentEvent?: (event: AgentEvent) => void,
) {
super(
AGENT_TOOL_NAME,
@@ -100,6 +104,7 @@ export class AgentTool extends BaseDeclarativeTool<
this.context,
_toolName,
_toolDisplayName,
this.onAgentEvent,
);
}
@@ -133,6 +138,7 @@ class DelegateInvocation extends BaseToolInvocation<
private readonly context: AgentLoopContext,
_toolName?: string,
_toolDisplayName?: string,
private readonly onAgentEvent?: (event: AgentEvent) => void,
) {
super(
params,
@@ -160,7 +166,21 @@ class DelegateInvocation extends BaseToolInvocation<
);
}
const useSession = this.context.config.isAgentSessionSubagentEnabled();
const options = this.onAgentEvent
? { onAgentEvent: this.onAgentEvent }
: undefined;
if (this.definition.kind === 'remote') {
if (useSession) {
return new RemoteSessionInvocation(
this.definition,
this.context,
agentArgs,
this.messageBus,
options,
);
}
return new RemoteAgentInvocation(
this.definition,
this.context,
@@ -168,6 +188,15 @@ class DelegateInvocation extends BaseToolInvocation<
this.messageBus,
);
} else {
if (useSession) {
return new LocalSessionInvocation(
this.definition,
this.context,
agentArgs,
this.messageBus,
options,
);
}
return new LocalSubagentInvocation(
this.definition,
this.context,
@@ -28,7 +28,6 @@ describe('policyCatalog', () => {
const chain = getModelPolicyChain({
previewEnabled: true,
useGemini31: true,
useGemini31FlashLite: false,
});
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_MODEL);
expect(chain).toHaveLength(2);
@@ -39,7 +38,6 @@ describe('policyCatalog', () => {
const chain = getModelPolicyChain({
previewEnabled: true,
useGemini31: true,
useGemini31FlashLite: false,
useCustomToolModel: true,
});
expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
@@ -33,6 +33,7 @@ export interface ModelPolicyOptions {
useGemini31?: boolean;
useGemini31FlashLite?: boolean;
useCustomToolModel?: boolean;
useGemini3_5Flash?: boolean;
}
const DEFAULT_ACTIONS: ModelPolicyActionMap = {
@@ -93,8 +94,10 @@ export function getModelPolicyChain(
const proModel = resolveModel(
PREVIEW_GEMINI_MODEL,
options.useGemini31,
options.useGemini31FlashLite,
options.useCustomToolModel,
true,
undefined,
options.useGemini3_5Flash,
);
return [
definePolicy({
@@ -30,7 +30,6 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config => {
getUserTier: () => undefined,
getModel: () => 'gemini-2.5-pro',
getGemini31LaunchedSync: () => false,
getGemini31FlashLiteLaunchedSync: () => false,
getUseCustomToolModelSync: () => {
const useGemini31 = config.getGemini31LaunchedSync();
const authType = config.getContentGeneratorConfig().authType;
@@ -114,7 +113,7 @@ describe('policyHelpers', () => {
});
const chain = resolvePolicyChain(config, DEFAULT_GEMINI_FLASH_LITE_MODEL);
expect(chain).toHaveLength(3);
expect(chain[0]?.model).toBe('gemini-2.5-flash-lite');
expect(chain[0]?.model).toBe('gemini-3.1-flash-lite');
expect(chain[1]?.model).toBe('gemini-2.5-flash');
expect(chain[2]?.model).toBe('gemini-2.5-pro');
});
@@ -125,7 +124,7 @@ describe('policyHelpers', () => {
});
const chain = resolvePolicyChain(config);
expect(chain).toHaveLength(3);
expect(chain[0]?.model).toBe('gemini-2.5-flash-lite');
expect(chain[0]?.model).toBe('gemini-3.1-flash-lite');
expect(chain[1]?.model).toBe('gemini-2.5-flash');
expect(chain[2]?.model).toBe('gemini-2.5-pro');
});
@@ -227,7 +226,6 @@ describe('policyHelpers', () => {
getExperimentalDynamicModelConfiguration: () => dynamic,
getModel: () => model,
getGemini31LaunchedSync: () => useGemini31 ?? false,
getGemini31FlashLiteLaunchedSync: () => false,
getHasAccessToPreviewModel: () => hasAccess ?? true,
getContentGeneratorConfig: () => ({ authType }),
getReleaseChannel: () => 'preview',
@@ -52,10 +52,9 @@ export function resolvePolicyChain(
let chain: ModelPolicyChain | undefined;
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false;
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? false;
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
// Capture the original family intent before any normalization or early downgrade.
const isOriginallyGemini3 = isGemini3Model(modelFromConfig, config);
@@ -64,10 +63,10 @@ export function resolvePolicyChain(
resolveModel(
modelFromConfig,
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
hasAccessToPreview,
config,
useGemini3_5Flash,
),
);
const isAutoPreferred = normalizedPreferredModel
@@ -84,8 +83,8 @@ export function resolvePolicyChain(
if (config.getExperimentalDynamicModelConfiguration?.() === true) {
const context = {
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
useGemini3_5Flash,
};
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
@@ -139,8 +138,8 @@ export function resolvePolicyChain(
isAutoSelection,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
useGemini3_5Flash,
});
} else {
// User requested Gemini 3 but has no access. Proactively downgrade
@@ -150,8 +149,8 @@ export function resolvePolicyChain(
isAutoSelection,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
useCustomToolModel,
useGemini3_5Flash,
});
}
} else {
+1 -1
View File
@@ -21,7 +21,7 @@ export function createAvailabilityServiceMock(
markHealthy: vi.fn(),
markRetryOncePerTurn: vi.fn(),
consumeStickyAttempt: vi.fn(),
snapshot: vi.fn(),
snapshot: vi.fn().mockReturnValue({ available: true }),
resetTurn: vi.fn(),
selectFirstAvailable: vi.fn().mockReturnValue(selection),
};
@@ -15,6 +15,7 @@ import {
} from './codeAssist.js';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
import { ModelMappingContentGenerator } from '../core/modelMappingContentGenerator.js';
import { UserTierId } from './types.js';
// Mock dependencies
@@ -22,11 +23,15 @@ vi.mock('./oauth2.js');
vi.mock('./setup.js');
vi.mock('./server.js');
vi.mock('../core/loggingContentGenerator.js');
vi.mock('../core/modelMappingContentGenerator.js');
const mockedGetOauthClient = vi.mocked(getOauthClient);
const mockedSetupUser = vi.mocked(setupUser);
const MockedCodeAssistServer = vi.mocked(CodeAssistServer);
const MockedLoggingContentGenerator = vi.mocked(LoggingContentGenerator);
const MockedModelMappingContentGenerator = vi.mocked(
ModelMappingContentGenerator,
);
describe('codeAssist', () => {
beforeEach(() => {
@@ -178,5 +183,47 @@ describe('codeAssist', () => {
const server = getCodeAssistServer(mockConfig);
expect(server).toBeUndefined();
});
it('should unwrap and return the server if it is wrapped in a ModelMappingContentGenerator', () => {
const mockServer = new MockedCodeAssistServer({} as never, '', {});
const mockMapper = new MockedModelMappingContentGenerator(
{} as never,
{},
);
vi.spyOn(mockMapper, 'getWrapped').mockReturnValue(mockServer);
const mockConfig = {
getContentGenerator: () => mockMapper,
} as unknown as Config;
const server = getCodeAssistServer(mockConfig);
expect(server).toBe(mockServer);
expect(mockMapper.getWrapped).toHaveBeenCalled();
});
it('should recursively unwrap multiple layers of LoggingContentGenerator and ModelMappingContentGenerator', () => {
const mockServer = new MockedCodeAssistServer({} as never, '', {});
const mockLogger = new MockedLoggingContentGenerator(
{} as never,
{} as never,
);
const mockMapper = new MockedModelMappingContentGenerator(
{} as never,
{},
);
// Mapper wraps Logger wraps Server
vi.spyOn(mockMapper, 'getWrapped').mockReturnValue(mockLogger);
vi.spyOn(mockLogger, 'getWrapped').mockReturnValue(mockServer);
const mockConfig = {
getContentGenerator: () => mockMapper,
} as unknown as Config;
const server = getCodeAssistServer(mockConfig);
expect(server).toBe(mockServer);
expect(mockMapper.getWrapped).toHaveBeenCalled();
expect(mockLogger.getWrapped).toHaveBeenCalled();
});
});
});
+10 -3
View File
@@ -10,6 +10,7 @@ import { setupUser } from './setup.js';
import { CodeAssistServer, type HttpOptions } from './server.js';
import type { Config } from '../config/config.js';
import { LoggingContentGenerator } from '../core/loggingContentGenerator.js';
import { ModelMappingContentGenerator } from '../core/modelMappingContentGenerator.js';
export async function createCodeAssistContentGenerator(
httpOptions: HttpOptions,
@@ -43,9 +44,15 @@ export function getCodeAssistServer(
): CodeAssistServer | undefined {
let server = config.getContentGenerator();
// Unwrap LoggingContentGenerator if present
if (server instanceof LoggingContentGenerator) {
server = server.getWrapped();
// Recursively unwrap LoggingContentGenerator and ModelMappingContentGenerator
while (true) {
if (server instanceof LoggingContentGenerator) {
server = server.getWrapped();
} else if (server instanceof ModelMappingContentGenerator) {
server = server.getWrapped();
} else {
break;
}
}
if (!(server instanceof CodeAssistServer)) {
@@ -18,8 +18,8 @@ export const ExperimentFlags = {
MASKING_PROTECT_LATEST_TURN: 45758819,
GEMINI_3_1_PRO_LAUNCHED: 45760185,
PRO_MODEL_NO_ACCESS: 45768879,
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
DEFAULT_REQUEST_TIMEOUT: 45773134,
GEMINI_3_5_FLASH_GA_LAUNCHED: 45780819,
} as const;
export type ExperimentFlagName =
@@ -180,14 +180,18 @@ describe('OAuthCredentialStorage', () => {
expect(result).toEqual(mockCredentials);
});
it('should throw an error if the migration file contains invalid JSON', async () => {
it('should return null and log a warning if the migration file contains invalid JSON', async () => {
vi.spyOn(mockHybridTokenStorage, 'getCredentials').mockResolvedValue(
null,
);
vi.spyOn(fs, 'readFile').mockResolvedValue('invalid json');
await expect(OAuthCredentialStorage.loadCredentials()).rejects.toThrow(
'Failed to load OAuth credentials',
const result = await OAuthCredentialStorage.loadCredentials();
expect(result).toBeNull();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Corrupted OAuth credential file'),
);
});
@@ -129,8 +129,17 @@ export class OAuthCredentialStorage {
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const credentials: Credentials = JSON.parse(credsJson);
let credentials: Credentials;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
credentials = JSON.parse(credsJson);
} catch {
coreEvents.emitFeedback(
'warning',
`Corrupted OAuth credential file at ${oldFilePath}, skipping migration`,
);
return null;
}
// Save to new storage
await this.saveCredentials(credentials);
+137 -14
View File
@@ -69,6 +69,7 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
} from './models.js';
import { Storage } from './storage.js';
import type { AgentLoopContext } from './agent-loop-context.js';
@@ -716,20 +717,6 @@ describe('Server Config (config.ts)', () => {
});
});
describe('getGemini31FlashLiteLaunchedSync', () => {
it.each([AuthType.USE_GEMINI, AuthType.USE_VERTEX_AI, AuthType.GATEWAY])(
'should return true for %s',
async (authType) => {
const config = new Config(baseParams);
vi.mocked(createContentGeneratorConfig).mockResolvedValue({
authType,
});
await config.refreshAuth(authType);
expect(config.getGemini31FlashLiteLaunchedSync()).toBe(true);
},
);
});
describe('getProModelNoAccessSync', () => {
it('should return experiment value for AuthType.LOGIN_WITH_GOOGLE', async () => {
vi.mocked(getExperiments).mockResolvedValue({
@@ -863,6 +850,16 @@ describe('Server Config (config.ts)', () => {
expect(GeminiClient).toHaveBeenCalledWith(config);
});
it('should clear fallback overrides when refreshing auth', async () => {
const config = new Config(baseParams);
config.activateFallbackMode('fallback-model', 'failed-model');
expect(config.getFallbackOverride('failed-model')).toBe('fallback-model');
await config.refreshAuth(AuthType.USE_GEMINI);
expect(config.getFallbackOverride('failed-model')).toBeUndefined();
});
it('should pass Vertex AI routing settings when refreshing auth', async () => {
const vertexAiRouting = {
requestType: 'shared' as const,
@@ -1902,6 +1899,21 @@ describe('Server Config (config.ts)', () => {
);
});
it('clears fallback overrides when session changes', async () => {
const config = new Config({
...baseParams,
sessionId: 'session-one',
});
await config.initialize();
config.activateFallbackMode('fallback-model', 'failed-model');
expect(config.getFallbackOverride('failed-model')).toBe('fallback-model');
config.setSessionId('session-two');
expect(config.getFallbackOverride('failed-model')).toBeUndefined();
});
it('does not throw when changing sessions before the previous plans dir exists', async () => {
const config = new Config({
...baseParams,
@@ -2715,6 +2727,16 @@ describe('Config getHooks', () => {
expect(spy).toHaveBeenCalled();
});
it('should preserve fallback overrides when setting a new model', () => {
const config = new Config(baseParams);
config.activateFallbackMode('fallback-model', 'failed-model');
expect(config.getFallbackOverride('failed-model')).toBe('fallback-model');
config.setModel('new-model');
expect(config.getFallbackOverride('failed-model')).toBe('fallback-model');
});
it('should allow setting auto model from auto model and reset availability', () => {
const config = new Config({
cwd: '/tmp',
@@ -3477,6 +3499,53 @@ describe('Config Quota & Preview Model Access', () => {
expect(await config.getPlanModeRoutingEnabled()).toBe(false);
});
});
describe('validatePathAccess (PathValidator integration)', () => {
it('should reject pathologically long paths', () => {
const config = new Config(baseParams);
const longPath = path.join(baseParams.targetDir, 'a'.repeat(5000));
const result = config.validatePathAccess(longPath, 'read');
expect(result).toContain('Invalid path: Path is too long');
});
it('should reject paths with log markers', () => {
const config = new Config(baseParams);
const logPath = path.join(
baseParams.targetDir,
'AssertionError: expected true to be false',
);
const result = config.validatePathAccess(logPath, 'read');
expect(result).toContain(
'Invalid path: Path appears to be a misinterpreted log fragment',
);
});
it('should reject paths with control characters', () => {
const config = new Config(baseParams);
const malformedPath = path.join(
baseParams.targetDir,
'file\nwith\nnewline.txt',
);
const result = config.validatePathAccess(malformedPath, 'read');
expect(result).toContain(
'Invalid path: Path contains invalid characters',
);
});
it('should allow normal paths', () => {
const config = new Config(baseParams);
const normalPath = path.resolve(baseParams.targetDir, 'src/index.ts');
const result = config.validatePathAccess(normalPath, 'read');
// It might return "Path not in workspace" or similar if not authorized,
// but it should NOT return the "Invalid path" prefix from PathValidator.
if (result) {
expect(result).not.toContain('Invalid path:');
} else {
expect(result).toBeNull();
}
});
});
});
describe('Config JIT Initialization', () => {
@@ -4278,3 +4347,57 @@ describe('ADKSettings', () => {
expect(config.getAgentSessionNoninteractiveEnabled()).toBe(true);
});
});
describe('hasGemini35FlashGAAccess model setting', () => {
const baseParams: ConfigParameters = {
sessionId: 'test',
targetDir: '.',
debugMode: false,
model: 'test-model',
cwd: '.',
};
it('should set DEFAULT_GEMINI_FLASH_MODEL to gemini-3.5-flash and PREVIEW_GEMINI_FLASH_MODEL to gemini-3-flash-preview if hasGemini35FlashGAAccess returns true and authType is USE_GEMINI', () => {
const config = new Config(baseParams);
config['contentGeneratorConfig'] = { authType: AuthType.USE_GEMINI };
// Set experiment to return true for GEMINI_3_5_FLASH_GA_LAUNCHED
config.setExperiments({
experimentIds: [],
flags: {
[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]: {
boolValue: true,
},
},
});
// Call the method
const result = config.hasGemini35FlashGAAccess();
expect(result).toBe(true);
expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3-flash-preview');
});
it('should set DEFAULT_GEMINI_FLASH_MODEL and PREVIEW_GEMINI_FLASH_MODEL to gemini-3.5-flash if hasGemini35FlashGAAccess returns true and authType is not USE_GEMINI', () => {
const config = new Config(baseParams);
config['contentGeneratorConfig'] = { authType: AuthType.LOGIN_WITH_GOOGLE };
// Set experiment to return true for GEMINI_3_5_FLASH_GA_LAUNCHED
config.setExperiments({
experimentIds: [],
flags: {
[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]: {
boolValue: true,
},
},
});
// Call the method
const result = config.hasGemini35FlashGAAccess();
expect(result).toBe(true);
expect(DEFAULT_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
expect(PREVIEW_GEMINI_FLASH_MODEL).toBe('gemini-3.5-flash');
});
});
+76 -33
View File
@@ -86,6 +86,7 @@ import {
isGemini2Model,
PREVIEW_GEMINI_FLASH_MODEL,
resolveModel,
setFlashModels,
} from './models.js';
import { shouldAttemptBrowserLaunch } from '../utils/browser.js';
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
@@ -177,6 +178,7 @@ import { startupProfiler } from '../telemetry/startupProfiler.js';
import type { AgentDefinition } from '../agents/types.js';
import { fetchAdminControls } from '../code_assist/admin/admin_controls.js';
import { isSubpath, resolveToRealPath } from '../utils/paths.js';
import { validatePath } from '../utils/path-validator.js';
import { InjectionService } from './injectionService.js';
import { ExecutionLifecycleService } from '../services/executionLifecycleService.js';
import { WORKSPACE_POLICY_TIER } from '../policy/config.js';
@@ -833,6 +835,7 @@ export class Config implements McpContext, AgentLoopContext {
private ideMode: boolean;
private _activeModel: string;
private fallbackOverrides = new Map<string, string>();
private readonly maxSessionTurns: number;
private readonly listSessions: boolean;
private readonly deleteSession: string | undefined;
@@ -1567,6 +1570,8 @@ export class Config implements McpContext, AgentLoopContext {
) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
this.fallbackOverrides.clear();
this.modelConfigService.clearRuntimeOverrides();
// Vertex and Genai have incompatible encryption and sending history with
// thoughtSignature from Genai to Vertex will fail, we need to strip them
@@ -1828,6 +1833,8 @@ export class Config implements McpContext, AgentLoopContext {
this._sessionId = sessionId;
this.storage.setSessionId(sessionId);
this.trackerService = undefined;
this.fallbackOverrides.clear();
this.modelConfigService.clearRuntimeOverrides();
this.approvedPlanPath = undefined;
this.topicState.reset();
this.skillManager.reset();
@@ -1923,14 +1930,40 @@ export class Config implements McpContext, AgentLoopContext {
this.modelAvailabilityService.reset();
}
activateFallbackMode(model: string): void {
this.setModel(model, true);
activateFallbackMode(model: string, failedModel?: string): void {
if (this.getActiveModel() !== model) {
this.setModel(model, true);
}
if (failedModel) {
// Chained fallback mitigation: If we already have overrides that point to the model
// that just failed, we need to update them to point to the new fallback model.
// e.g. A -> B, then B fails and we fallback to C. We must update A to point to C.
for (const [source, target] of this.fallbackOverrides.entries()) {
if (target === failedModel) {
this.fallbackOverrides.set(source, model);
this.modelConfigService.registerRuntimeModelOverride({
match: { model: source },
modelConfig: { model },
});
}
}
this.fallbackOverrides.set(failedModel, model);
this.modelConfigService.registerRuntimeModelOverride({
match: { model: failedModel },
modelConfig: { model },
});
}
const authType = this.getContentGeneratorConfig()?.authType;
if (authType) {
logFlashFallback(this, new FlashFallbackEvent(authType));
}
}
getFallbackOverride(model: string): string | undefined {
return this.fallbackOverrides.get(model);
}
getActiveModel(): string {
return this._activeModel ?? this.model;
}
@@ -2019,10 +2052,10 @@ export class Config implements McpContext, AgentLoopContext {
const primaryModel = resolveModel(
model,
this.getGemini31LaunchedSync(),
this.getGemini31FlashLiteLaunchedSync(),
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
this.hasGemini35FlashGAAccess(),
);
const isPreview = isPreviewModel(primaryModel, this);
@@ -2059,10 +2092,10 @@ export class Config implements McpContext, AgentLoopContext {
const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
this.getGemini31FlashLiteLaunchedSync(),
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
this.hasGemini35FlashGAAccess(),
);
return this.modelQuotas.get(primaryModel)?.remaining;
}
@@ -2075,10 +2108,10 @@ export class Config implements McpContext, AgentLoopContext {
const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
this.getGemini31FlashLiteLaunchedSync(),
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
this.hasGemini35FlashGAAccess(),
);
return this.modelQuotas.get(primaryModel)?.limit;
}
@@ -2091,10 +2124,10 @@ export class Config implements McpContext, AgentLoopContext {
const primaryModel = resolveModel(
this.getModel(),
this.getGemini31LaunchedSync(),
this.getGemini31FlashLiteLaunchedSync(),
this.getUseCustomToolModelSync(),
this.getHasAccessToPreviewModel(),
this,
this.hasGemini35FlashGAAccess(),
);
return this.modelQuotas.get(primaryModel)?.resetTime;
}
@@ -3299,6 +3332,11 @@ export class Config implements McpContext, AgentLoopContext {
absolutePath: string,
checkType: 'read' | 'write' = 'write',
): string | null {
const pathValidation = validatePath(absolutePath);
if (!pathValidation.isValid) {
return `Invalid path: ${pathValidation.error}`;
}
if (checkType === 'write' && hasScopedAutoMemoryExtractionWriteAccess()) {
const resolvedPath = resolveToRealPath(absolutePath);
if (
@@ -3476,15 +3514,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.getGemini31LaunchedSync();
}
/**
* Returns whether Gemini 3.1 Flash Lite has been launched.
* This method is async and ensures that experiments are loaded before returning the result.
*/
async getGemini31FlashLiteLaunched(): Promise<boolean> {
await this.ensureExperimentsLoaded();
return this.getGemini31FlashLiteLaunchedSync();
}
/**
* Returns whether the custom tool model should be used.
*/
@@ -3513,6 +3542,38 @@ export class Config implements McpContext, AgentLoopContext {
);
}
/**
* Returns whether Gemini 3.5 Flash GA has been launched.
*
* Note: This method should only be called after startup, once experiments have been loaded.
*/
hasGemini35FlashGAAccess(): boolean {
const authType = this.contentGeneratorConfig?.authType;
const hasAccess = (() => {
if (this.isGemini31LaunchedForAuthType(authType)) {
return true;
}
return (
this.experiments?.flags[ExperimentFlags.GEMINI_3_5_FLASH_GA_LAUNCHED]
?.boolValue ?? false
);
})();
// Used to set default flash models based on access
// TODO: Remove once the experiment for 3_5 flash rollut can be cleaned up.
if (hasAccess) {
// Gemini API key users should have the ability to manually select the
// old preview flash model.
if (authType === AuthType.USE_GEMINI) {
setFlashModels('gemini-3-flash-preview', 'gemini-3.5-flash');
} else {
setFlashModels('gemini-3.5-flash', 'gemini-3.5-flash');
}
} else {
setFlashModels('gemini-3-flash-preview', 'gemini-2.5-flash');
}
return hasAccess;
}
/**
* Returns whether Gemini 3.1 has been launched.
*
@@ -3546,24 +3607,6 @@ export class Config implements McpContext, AgentLoopContext {
return undefined;
}
/**
* Returns whether Gemini 3.1 Flash Lite has been launched.
*
* Note: This method should only be called after startup, once experiments have been loaded.
* If you need to call this during startup or from an async context, use
* getGemini31FlashLiteLaunched instead.
*/
getGemini31FlashLiteLaunchedSync(): boolean {
const authType = this.contentGeneratorConfig?.authType;
if (this.isGemini31LaunchedForAuthType(authType)) {
return true;
}
return (
this.experiments?.flags[ExperimentFlags.GEMINI_3_1_FLASH_LITE_LAUNCHED]
?.boolValue ?? false
);
}
/**
* Returns the client version.
*/
+64 -26
View File
@@ -107,6 +107,18 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-2.5-flash-lite',
},
},
'gemini-3.1-flash-lite': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.1-flash-lite',
},
},
'gemini-3.5-flash': {
extends: 'chat-base-3',
modelConfig: {
model: 'gemini-3.5-flash',
},
},
'gemma-4-31b-it': {
extends: 'chat-base-3',
modelConfig: {
@@ -133,10 +145,16 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-flash-preview',
},
},
'gemini-3.5-flash-base': {
extends: 'base',
modelConfig: {
model: 'gemini-3.5-flash',
},
},
classifier: {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
model: 'flash-lite',
generateContentConfig: {
maxOutputTokens: 1024,
thinkingConfig: {
@@ -148,7 +166,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'prompt-completion': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
model: 'flash-lite',
generateContentConfig: {
temperature: 0.3,
maxOutputTokens: 16000,
@@ -161,7 +179,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'fast-ack-helper': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
model: 'flash-lite',
generateContentConfig: {
temperature: 0.2,
maxOutputTokens: 120,
@@ -174,7 +192,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'edit-corrector': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
model: 'flash-lite',
generateContentConfig: {
thinkingConfig: {
thinkingBudget: 0,
@@ -185,7 +203,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'summarizer-default': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
model: 'flash-lite',
generateContentConfig: {
maxOutputTokens: 2000,
},
@@ -194,7 +212,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
'summarizer-shell': {
extends: 'base',
modelConfig: {
model: 'gemini-2.5-flash-lite',
model: 'flash-lite',
generateContentConfig: {
maxOutputTokens: 2000,
},
@@ -264,7 +282,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
'chat-compression-3.1-flash-lite': {
modelConfig: {
model: 'gemini-3.1-flash-lite-preview',
model: 'gemini-3.1-flash-lite',
},
},
'chat-compression-2.5-pro': {
@@ -305,10 +323,10 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
],
modelDefinitions: {
// Concrete Models
'gemini-3.1-flash-lite-preview': {
'gemini-3.1-flash-lite': {
tier: 'flash-lite',
family: 'gemini-3',
isPreview: true,
isPreview: false,
isVisible: true,
features: { thinking: false, multimodalToolUse: true },
},
@@ -340,6 +358,13 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
isVisible: true,
features: { thinking: false, multimodalToolUse: true },
},
'gemini-3.5-flash': {
tier: 'flash',
family: 'gemini-3',
isPreview: false,
isVisible: true,
features: { thinking: false, multimodalToolUse: true },
},
'gemini-2.5-pro': {
tier: 'pro',
family: 'gemini-2.5',
@@ -445,11 +470,34 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
default: 'gemini-3-flash-preview',
contexts: [
{
condition: { hasAccessToPreview: false },
condition: { hasAccessToPreview: false, useGemini3_5Flash: true },
target: 'gemini-3.5-flash',
},
{
condition: { hasAccessToPreview: false, useGemini3_5Flash: false },
target: 'gemini-2.5-flash',
},
],
},
'gemini-3.5-flash': {
default: 'gemini-3.5-flash',
contexts: [
{
condition: { useGemini3_5Flash: false, hasAccessToPreview: false },
target: 'gemini-2.5-flash',
},
{
condition: { useGemini3_5Flash: false },
target: 'gemini-3-flash-preview',
},
],
},
'gemini-2.5-flash': {
default: 'gemini-2.5-flash',
contexts: [
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
],
},
'gemini-3-pro-preview': {
default: 'gemini-3-pro-preview',
contexts: [
@@ -492,18 +540,13 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
],
},
'gemini-3.1-flash-lite-preview': {
default: 'gemini-3.1-flash-lite-preview',
contexts: [
{
condition: { useGemini3_1FlashLite: false },
target: 'gemini-2.5-flash-lite',
},
],
'gemini-3.1-flash-lite': {
default: 'gemini-3.1-flash-lite',
},
flash: {
default: 'gemini-3-flash-preview',
contexts: [
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
{
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-flash',
@@ -511,13 +554,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
],
},
'flash-lite': {
default: 'gemini-2.5-flash-lite',
contexts: [
{
condition: { useGemini3_1FlashLite: true },
target: 'gemini-3.1-flash-lite-preview',
},
],
default: 'gemini-3.1-flash-lite',
},
'auto-gemini-3': {
default: 'gemini-3-pro-preview',
@@ -541,6 +578,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
flash: {
default: 'gemini-3-flash-preview',
contexts: [
{ condition: { useGemini3_5Flash: true }, target: 'gemini-3.5-flash' },
{
condition: { hasAccessToPreview: false },
target: 'gemini-2.5-flash',
@@ -714,7 +752,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
],
lite: [
{
model: 'gemini-2.5-flash-lite',
model: 'flash-lite',
actions: {
terminal: 'silent',
transient: 'silent',
@@ -73,5 +73,58 @@ describe('Flash Model Fallback Configuration', () => {
expect.any(FlashFallbackEvent),
);
});
it('should set fallback override when failedModel is provided and register runtime override', () => {
config.activateFallbackMode(
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL,
);
expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(config.getFallbackOverride(DEFAULT_GEMINI_MODEL)).toBe(
DEFAULT_GEMINI_FLASH_MODEL,
);
// Verify it registers the runtime model override with ModelConfigService
expect(
config
.getModelConfigService()
.getResolvedConfig({ model: DEFAULT_GEMINI_MODEL }).model,
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should flatten override chains when a model that was previously a target fails', () => {
// 1. Initial fallback: A -> B
config.activateFallbackMode('model-B', 'model-A');
expect(config.getFallbackOverride('model-A')).toBe('model-B');
expect(
config.getModelConfigService().getResolvedConfig({ model: 'model-A' })
.model,
).toBe('model-B');
// 2. Chained fallback: B fails, fallback to C
// This should update A -> C as well.
config.activateFallbackMode('model-C', 'model-B');
expect(config.getFallbackOverride('model-A')).toBe('model-C');
expect(config.getFallbackOverride('model-B')).toBe('model-C');
expect(
config.getModelConfigService().getResolvedConfig({ model: 'model-A' })
.model,
).toBe('model-C');
expect(
config.getModelConfigService().getResolvedConfig({ model: 'model-B' })
.model,
).toBe('model-C');
});
it('should not reset availability service if model has not changed', () => {
const resetSpy = vi.spyOn(config.getModelAvailabilityService(), 'reset');
const currentModel = config.getActiveModel();
config.activateFallbackMode(currentModel);
expect(resetSpy).not.toHaveBeenCalled();
});
});
});
+408 -83
View File
@@ -17,6 +17,7 @@ import {
DEFAULT_GEMINI_MODEL,
PREVIEW_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_3_5_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
supportsMultimodalFunctionResponse,
GEMINI_MODEL_ALIAS_PRO,
@@ -28,7 +29,7 @@ import {
DEFAULT_GEMINI_MODEL_AUTO,
isActiveModel,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isPreviewModel,
isProModel,
@@ -67,22 +68,14 @@ describe('Dynamic Configuration Parity', () => {
const flagCombos = [
{
useGemini3_1: false,
useGemini3_1FlashLite: false,
useCustomToolModel: false,
},
{
useGemini3_1: true,
useGemini3_1FlashLite: false,
useCustomToolModel: false,
},
{
useGemini3_1: true,
useGemini3_1FlashLite: true,
useCustomToolModel: false,
},
{
useGemini3_1: true,
useGemini3_1FlashLite: true,
useCustomToolModel: true,
},
];
@@ -105,7 +98,6 @@ describe('Dynamic Configuration Parity', () => {
const legacy = resolveModel(
model,
flags.useGemini3_1,
flags.useGemini3_1FlashLite,
flags.useCustomToolModel,
hasAccess,
mockLegacyConfig,
@@ -113,7 +105,6 @@ describe('Dynamic Configuration Parity', () => {
const dynamic = resolveModel(
model,
flags.useGemini3_1,
flags.useGemini3_1FlashLite,
flags.useCustomToolModel,
hasAccess,
mockDynamicConfig,
@@ -152,7 +143,6 @@ describe('Dynamic Configuration Parity', () => {
anchor,
tier,
flags.useGemini3_1,
flags.useGemini3_1FlashLite,
flags.useCustomToolModel,
hasAccess,
mockLegacyConfig,
@@ -161,7 +151,6 @@ describe('Dynamic Configuration Parity', () => {
anchor,
tier,
flags.useGemini3_1,
flags.useGemini3_1FlashLite,
flags.useCustomToolModel,
hasAccess,
mockDynamicConfig,
@@ -229,12 +218,29 @@ describe('Dynamic Configuration Parity', () => {
});
describe('isPreviewModel', () => {
it('should return true for preview models', () => {
expect(isPreviewModel(PREVIEW_GEMINI_MODEL)).toBe(true);
expect(isPreviewModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(true);
expect(isPreviewModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL)).toBe(true);
expect(isPreviewModel(PREVIEW_GEMINI_FLASH_MODEL)).toBe(true);
const PREVIEW_MODELS = [
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
];
it('should return true for active preview models', () => {
for (const model of PREVIEW_MODELS) {
if (model !== 'none') {
expect(isPreviewModel(model)).toBe(true);
}
}
expect(isPreviewModel(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true);
expect(isPreviewModel(GEMINI_MODEL_ALIAS_AUTO)).toBe(true);
});
it('should return false if a preview model is retired (set to none)', () => {
const retiredModels = PREVIEW_MODELS.filter((m) => m === 'none');
for (const model of retiredModels) {
expect(isPreviewModel(model)).toBe(false);
}
});
it('should return false for non-preview models', () => {
@@ -358,9 +364,9 @@ describe('getDisplayString', () => {
);
});
it('should return PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL for PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL', () => {
expect(getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
it('should return PREVIEW_GEMINI_FLASH_LITE_MODEL for PREVIEW_GEMINI_FLASH_LITE_MODEL', () => {
expect(getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL)).toBe(
PREVIEW_GEMINI_FLASH_LITE_MODEL,
);
});
@@ -405,7 +411,7 @@ describe('resolveModel', () => {
});
it('should return Gemini 3.1 Pro Custom Tools when auto-gemini-3 is requested, useGemini3_1 is true, and useCustomToolModel is true', () => {
const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, true);
const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, true);
expect(model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
});
@@ -419,9 +425,9 @@ describe('resolveModel', () => {
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return the Preview Flash-Lite model when flash-lite is requested and useGemini3_1FlashLite is true', () => {
const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE, false, true);
expect(model).toBe(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
it('should return the Flash-Lite model when flash-lite is requested', () => {
const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE, false);
expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return the requested model as-is for explicit specific models', () => {
@@ -452,45 +458,39 @@ describe('resolveModel', () => {
describe('hasAccessToPreview logic', () => {
it('should return default model when access to preview is false and preview model is requested', () => {
expect(
resolveModel(PREVIEW_GEMINI_MODEL, false, false, false, false),
).toBe(DEFAULT_GEMINI_MODEL);
expect(resolveModel(PREVIEW_GEMINI_MODEL, false, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
);
});
it('should return default flash model when access to preview is false and preview flash model is requested', () => {
expect(
resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false, false),
resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should return default flash lite model when access to preview is false and preview flash lite model is requested', () => {
expect(
resolveModel(
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
false,
false,
false,
false,
),
resolveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, false, false, false),
).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
it('should return default model when access to preview is false and auto-gemini-3 is requested', () => {
expect(
resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false, false),
).toBe(DEFAULT_GEMINI_MODEL);
expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
);
});
it('should return default model when access to preview is false and Gemini 3.1 is requested', () => {
expect(
resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false, false),
).toBe(DEFAULT_GEMINI_MODEL);
expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
);
});
it('should still return default model when access to preview is false and auto-gemini-2.5 is requested', () => {
expect(
resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false, false),
).toBe(DEFAULT_GEMINI_MODEL);
expect(resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false)).toBe(
DEFAULT_GEMINI_MODEL,
);
});
});
});
@@ -582,7 +582,6 @@ describe('resolveClassifierModel', () => {
PREVIEW_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_PRO,
true,
false,
true,
),
).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL);
@@ -599,17 +598,14 @@ describe('isActiveModel', () => {
it('should return true for Gemma 4 models when experimentalGemma is not provided (defaults to true)', () => {
expect(isActiveModel(GEMMA_4_31B_IT_MODEL)).toBe(true);
expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL)).toBe(true);
expect(isActiveModel(GEMMA_4_31B_IT_MODEL, false, false, false, true)).toBe(
expect(isActiveModel(GEMMA_4_31B_IT_MODEL, false, false, true)).toBe(true);
expect(isActiveModel(GEMMA_4_26B_A4B_IT_MODEL, false, false, true)).toBe(
true,
);
expect(
isActiveModel(GEMMA_4_26B_A4B_IT_MODEL, false, false, false, true),
).toBe(true);
});
it('should return false for Gemini 3.1 models when Gemini 3.1 is not launched', () => {
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(false);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(false);
});
it('should return true for unknown models and aliases', () => {
@@ -625,37 +621,48 @@ describe('isActiveModel', () => {
expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true);
});
it('should return true for PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL only when useGemini3_1FlashLite is true', () => {
expect(
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, true),
).toBe(true);
expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true, true)).toBe(
it('should handle PREVIEW_GEMINI_FLASH_LITE_MODEL activity correctly based on retirement status', () => {
if (PREVIEW_GEMINI_FLASH_LITE_MODEL === 'none') {
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, false, true)).toBe(
false,
);
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, true, true)).toBe(
false,
);
} else {
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, false, true)).toBe(
true,
);
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, true, true)).toBe(
true,
);
}
expect(isActiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, false, false)).toBe(
true,
);
expect(isActiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, true, true)).toBe(
true,
);
expect(isActiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, true, false)).toBe(
true,
);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true, false),
).toBe(false);
});
it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => {
// When custom tools are preferred, standard 3.1 should be inactive
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false, true)).toBe(
false,
);
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, true)).toBe(false);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false, true),
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, true),
).toBe(true);
// When custom tools are NOT preferred, custom tools 3.1 should be inactive
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false, false)).toBe(
true,
);
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false)).toBe(true);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false, false),
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false),
).toBe(false);
});
it('should return false for Gemini 3.1 models when useGemini3_1 and useGemini3_1FlashLite are false', () => {
it('should return false for Gemini 3.1 preview models when useGemini3_1 is false', () => {
expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false, true)).toBe(
false,
);
@@ -668,9 +675,14 @@ describe('isActiveModel', () => {
expect(
isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false, false),
).toBe(false);
expect(
isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, false),
).toBe(false);
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
expect(isActiveModel(PREVIEW_GEMINI_FLASH_LITE_MODEL, false, false)).toBe(
false,
);
}
expect(isActiveModel(DEFAULT_GEMINI_FLASH_LITE_MODEL, false, false)).toBe(
true,
);
});
});
@@ -695,14 +707,23 @@ describe('Gemini 3.1 Config Resolution', () => {
).toBeDefined();
});
it('PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL should resolve to chat-base-3 config (including thinkingLevel)', () => {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
it('PREVIEW_GEMINI_FLASH_LITE_MODEL should resolve to appropriate config based on retirement status', () => {
if (PREVIEW_GEMINI_FLASH_LITE_MODEL === 'none') {
// If none, it falls back to chat-base which may not have thinkingLevel
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_FLASH_LITE_MODEL,
isChatModel: true,
});
expect(resolved.model).toBe(PREVIEW_GEMINI_FLASH_LITE_MODEL);
} else {
const resolved = modelConfigService.getResolvedConfig({
model: PREVIEW_GEMINI_FLASH_LITE_MODEL,
isChatModel: true,
});
expect(
resolved.generateContentConfig?.thinkingConfig?.thinkingLevel,
).toBeDefined();
}
});
});
@@ -715,13 +736,317 @@ describe('getAutoModelDescription', () => {
it('should return Gemini 3.0 description when hasAccessToPreview is true', () => {
const desc = getAutoModelDescription(true, false);
expect(desc).toContain('gemini-3-pro');
expect(desc).toContain('gemini-3-flash');
expect(desc).toContain('gemini-3-pro-preview');
expect(desc).toContain('gemini-3-flash-preview');
});
it('should return Gemini 3.1 description when hasAccessToPreview and useGemini3_1 are true', () => {
const desc = getAutoModelDescription(true, true);
expect(desc).toContain('gemini-3.1-pro');
expect(desc).toContain('gemini-3-flash');
expect(desc).toContain('gemini-3.1-pro-preview');
expect(desc).toContain('gemini-3-flash-preview');
});
it('should return Gemini 3.5 Flash description when hasAccessToPreview and useGemini3_5Flash are true', () => {
const desc = getAutoModelDescription(true, true, true);
expect(desc).toContain('gemini-3.1-pro-preview');
expect(desc).toContain(DEFAULT_GEMINI_3_5_FLASH_MODEL);
});
});
describe('resolveModel Gemini 3.5 Flash GA', () => {
it('should resolve all but preview flash models to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (legacy)', () => {
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(
resolveModel(
DEFAULT_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
true,
),
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should resolve all but preview flash models to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
expect(
resolveModel(
DEFAULT_GEMINI_FLASH_MODEL,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should NOT resolve flash models to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is false', () => {
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
false,
),
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
expect(
resolveModel(
DEFAULT_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
false,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
false,
),
).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should resolve to DEFAULT_GEMINI_FLASH_MODEL when GA is false AND preview access is false (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
DEFAULT_GEMINI_FLASH_MODEL,
false,
false,
false, // No preview access
mockDynamicConfig,
false, // GA false
),
).toBe('gemini-2.5-flash');
});
it('should resolve auto to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true and classifier selects flash', () => {
expect(
resolveClassifierModel(
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should resolve auto to gemini-3.5-flash when useGemini3_5Flash is true and classifier selects flash (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveClassifierModel(
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
});
describe('Flash model promotion and manual override routing logic', () => {
it('should resolve flash alias to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (static)', () => {
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should resolve flash alias to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
});
it('should resolve manual selection of gemini-3-flash-preview to gemini-3-flash-preview when useGemini3_5Flash is true and has preview access (static)', () => {
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
undefined,
true,
),
).toBe('gemini-3-flash-preview');
});
it('should resolve manual selection of gemini-3-flash-preview to gemini-3-flash-preview when useGemini3_5Flash is true and has preview access (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3-flash-preview');
});
it('should resolve manual selection of gemini-3-flash-preview to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true but lacks preview access (static)', () => {
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
false,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should resolve manual selection of gemini-3-flash-preview to gemini-3.5-flash when useGemini3_5Flash is true but lacks preview access (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false,
false,
false,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
});
it('should resolve classifier-selected flash alias to DEFAULT_GEMINI_FLASH_MODEL when useGemini3_5Flash is true (static)', () => {
expect(
resolveClassifierModel(
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
undefined,
true,
),
).toBe(DEFAULT_GEMINI_FLASH_MODEL);
});
it('should resolve classifier-selected flash alias to gemini-3.5-flash when useGemini3_5Flash is true (dynamic)', () => {
const mockDynamicConfig = {
getExperimentalDynamicModelConfiguration: () => true,
modelConfigService,
} as unknown as Config;
expect(
resolveClassifierModel(
GEMINI_MODEL_ALIAS_AUTO,
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
true,
mockDynamicConfig,
true,
),
).toBe('gemini-3.5-flash');
});
it('should resolve auto to PREVIEW_GEMINI_MODEL when useGemini3_5Flash is true and has preview access', () => {
expect(
resolveModel(
GEMINI_MODEL_ALIAS_AUTO,
false,
false,
true, // hasAccessToPreview
undefined,
true, // useGemini3_5Flash
),
).toBe(PREVIEW_GEMINI_MODEL);
});
});
});
+95 -33
View File
@@ -6,7 +6,7 @@
export interface ModelResolutionContext {
useGemini3_1?: boolean;
useGemini3_1FlashLite?: boolean;
useGemini3_5Flash?: boolean;
useCustomTools?: boolean;
hasAccessToPreview?: boolean;
requestedModel?: string;
@@ -55,12 +55,32 @@ export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
'gemini-3.1-pro-preview-customtools';
export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
export const PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL =
'gemini-3.1-flash-lite-preview';
// TODO: set to none and const once the experiment for 3_5 flash rollut can be
// cleaned up.
export let PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview';
export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro';
export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite';
// TODO: Set to const and update to 'gemini-3.5-flash' once the experiment for
// 3_5 flash rollut can be cleaned up.
// This is set to either the same as the DEFAULT_GEMINI_3_5_FLASH_MODEL const
// OR the SECONDARY_GEMINI_3_5_FLASH_MODEL depending on which is needed for
// the user's backend as determined by hasGemini35FlashGAAccess in
// packages/core/src/config/config.ts
export let DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash';
export const DEFAULT_GEMINI_3_5_FLASH_MODEL = 'gemini-3.5-flash';
// This is resolved to 3.5 flash in backends where it is used,
// however those backends do not expect to see the string gemini-3.5-flash
// so we need to provide this model as an alternative name in certain instances.
export const SECONDARY_GEMINI_3_5_FLASH_MODEL = 'gemini-3-flash';
// Used to set default flash models based on access
// TODO: Cleanup once the experiment for 3_5 flash rollut can be cleaned up.
export function setFlashModels(preview: string, defaultFlash: string) {
PREVIEW_GEMINI_FLASH_MODEL = preview;
DEFAULT_GEMINI_FLASH_MODEL = defaultFlash;
}
export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-3.1-flash-lite';
/** @deprecated Gemini 3.1 Flash Lite is now GA. Use DEFAULT_GEMINI_FLASH_LITE_MODEL. */
export const PREVIEW_GEMINI_FLASH_LITE_MODEL = 'none';
export const GEMMA_4_31B_IT_MODEL = 'gemma-4-31b-it';
export const GEMMA_4_26B_A4B_IT_MODEL = 'gemma-4-26b-a4b-it';
@@ -70,9 +90,11 @@ export const VALID_GEMINI_MODELS = new Set([
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_3_5_FLASH_MODEL,
SECONDARY_GEMINI_3_5_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
GEMMA_4_31B_IT_MODEL,
@@ -98,14 +120,19 @@ export const DEFAULT_THINKING_MODE = 8192;
export function getAutoModelDescription(
hasAccessToPreview: boolean,
useGemini3_1: boolean = false,
useGemini3_5Flash: boolean = false,
) {
const proModel = hasAccessToPreview
? useGemini3_1
? 'gemini-3.1-pro'
: 'gemini-3-pro'
: 'gemini-2.5-pro';
const flashModel = hasAccessToPreview ? 'gemini-3-flash' : 'gemini-2.5-flash';
return `Let Gemini CLI decide the best model for the task: ${proModel}, ${flashModel}`;
? PREVIEW_GEMINI_3_1_MODEL
: PREVIEW_GEMINI_MODEL
: DEFAULT_GEMINI_MODEL;
const flashModel = hasAccessToPreview
? useGemini3_5Flash
? DEFAULT_GEMINI_3_5_FLASH_MODEL
: PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
return `Let Gemini CLI decide the best model for the task: ${getDisplayString(proModel)}, ${getDisplayString(flashModel)}`;
}
/**
@@ -114,16 +141,17 @@ export function getAutoModelDescription(
*
* @param requestedModel The model alias or concrete model name requested by the user.
* @param useGemini3_1 Whether to use Gemini 3.1 Pro Preview for auto/pro aliases.
* @param useGemini3_5Flash Whether to use Gemini 3.5 Flash GA.
* @param hasAccessToPreview Whether the user has access to preview models.
* @returns The resolved concrete model name.
*/
export function resolveModel(
requestedModel: string,
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
hasAccessToPreview: boolean = true,
config?: ModelCapabilityContext,
useGemini3_5Flash: boolean = false,
): string {
// Defensive check against non-string inputs at runtime
const normalizedModel = Array.isArray(requestedModel)
@@ -135,9 +163,9 @@ export function resolveModel(
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const resolved = config.modelConfigService.resolveModelId(normalizedModel, {
useGemini3_1,
useGemini3_1FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
useGemini3_5Flash,
});
if (!hasAccessToPreview && isPreviewModel(resolved, config)) {
@@ -180,13 +208,13 @@ export function resolveModel(
break;
}
case GEMINI_MODEL_ALIAS_FLASH: {
resolved = PREVIEW_GEMINI_FLASH_MODEL;
resolved = useGemini3_5Flash
? DEFAULT_GEMINI_FLASH_MODEL
: PREVIEW_GEMINI_FLASH_MODEL;
break;
}
case GEMINI_MODEL_ALIAS_FLASH_LITE: {
resolved = useGemini3_1FlashLite
? PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL
: DEFAULT_GEMINI_FLASH_LITE_MODEL;
resolved = DEFAULT_GEMINI_FLASH_LITE_MODEL;
break;
}
default: {
@@ -195,13 +223,23 @@ export function resolveModel(
}
}
if (resolved === 'none') {
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
}
if (
useGemini3_5Flash &&
isFlashModel(resolved) &&
normalizedModel !== PREVIEW_GEMINI_FLASH_MODEL
) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
if (!hasAccessToPreview && isPreviewModel(resolved)) {
// Downgrade to stable models if user lacks preview access.
switch (resolved) {
case PREVIEW_GEMINI_FLASH_MODEL:
return DEFAULT_GEMINI_FLASH_MODEL;
case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL:
return DEFAULT_GEMINI_FLASH_LITE_MODEL;
case PREVIEW_GEMINI_MODEL:
case PREVIEW_GEMINI_3_1_MODEL:
case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL:
@@ -221,6 +259,17 @@ export function resolveModel(
return resolved;
}
function isFlashModel(model: string): boolean {
return (
model === DEFAULT_GEMINI_FLASH_MODEL ||
model === PREVIEW_GEMINI_FLASH_MODEL ||
model === DEFAULT_GEMINI_3_5_FLASH_MODEL ||
model === SECONDARY_GEMINI_3_5_FLASH_MODEL ||
model === 'flash' ||
model.endsWith('flash')
);
}
/**
* Resolves the appropriate model based on the classifier's decision.
*
@@ -235,10 +284,10 @@ export function resolveClassifierModel(
requestedModel: string,
modelAlias: string,
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
hasAccessToPreview: boolean = true,
config?: ModelCapabilityContext,
useGemini3_5Flash: boolean = false,
): string {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return config.modelConfigService.resolveClassifierModelId(
@@ -246,9 +295,9 @@ export function resolveClassifierModel(
requestedModel,
{
useGemini3_1,
useGemini3_1FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview,
useGemini3_5Flash,
},
);
}
@@ -265,6 +314,9 @@ export function resolveClassifierModel(
requestedModel === PREVIEW_GEMINI_MODEL ||
requestedModel === GEMINI_MODEL_ALIAS_AUTO
) {
if (useGemini3_5Flash) {
return DEFAULT_GEMINI_FLASH_MODEL;
}
return hasAccessToPreview
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
@@ -273,16 +325,18 @@ export function resolveClassifierModel(
GEMINI_MODEL_ALIAS_FLASH,
false,
false,
false,
hasAccessToPreview,
config,
useGemini3_5Flash,
);
}
return resolveModel(
requestedModel,
useGemini3_1,
useGemini3_1FlashLite,
useCustomToolModel,
hasAccessToPreview,
config,
useGemini3_5Flash,
);
}
@@ -298,6 +352,8 @@ export function getDisplayString(
}
switch (model) {
case 'gemini-3-flash':
return DEFAULT_GEMINI_3_5_FLASH_MODEL;
case GEMINI_MODEL_ALIAS_AUTO:
return 'Auto';
case PREVIEW_GEMINI_MODEL_AUTO:
@@ -314,8 +370,8 @@ export function getDisplayString(
return PREVIEW_GEMINI_FLASH_MODEL;
case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL:
return PREVIEW_GEMINI_3_1_MODEL;
case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL:
return PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL;
case PREVIEW_GEMINI_FLASH_LITE_MODEL:
return PREVIEW_GEMINI_FLASH_LITE_MODEL;
default:
return model;
}
@@ -332,6 +388,9 @@ export function isPreviewModel(
model: string,
config?: ModelCapabilityContext,
): boolean {
if (model === 'none') {
return false;
}
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
return (
config.modelConfigService.getModelDefinition(model)?.isPreview === true
@@ -345,7 +404,7 @@ export function isPreviewModel(
model === PREVIEW_GEMINI_FLASH_MODEL ||
model === PREVIEW_GEMINI_MODEL_AUTO ||
model === GEMINI_MODEL_ALIAS_AUTO ||
model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL
model === PREVIEW_GEMINI_FLASH_LITE_MODEL
);
}
@@ -379,7 +438,7 @@ export function isGemini3Model(
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
// Legacy behavior resolves the model first.
const resolved = resolveModel(model, false, false, false, true, config);
const resolved = resolveModel(model, false, false, true, config);
return (
config.modelConfigService.getModelDefinition(resolved)?.family ===
'gemini-3'
@@ -414,7 +473,7 @@ export function isCustomModel(
config?: ModelCapabilityContext,
): boolean {
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
const resolved = resolveModel(model, false, false, false, true, config);
const resolved = resolveModel(model, false, false, true, config);
return (
config.modelConfigService.getModelDefinition(resolved)?.tier ===
'custom' || !resolved.startsWith('gemini-')
@@ -487,18 +546,17 @@ export function supportsMultimodalFunctionResponse(
export function isActiveModel(
model: string,
useGemini3_1: boolean = false,
useGemini3_1FlashLite: boolean = false,
useCustomToolModel: boolean = false,
experimentalGemma: boolean = true,
): boolean {
if (!VALID_GEMINI_MODELS.has(model)) {
if (!VALID_GEMINI_MODELS.has(model) || model === 'none') {
return false;
}
if (model === GEMMA_4_31B_IT_MODEL || model === GEMMA_4_26B_A4B_IT_MODEL) {
return experimentalGemma;
}
if (model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL) {
return useGemini3_1FlashLite;
if (model === PREVIEW_GEMINI_FLASH_LITE_MODEL) {
return false;
}
if (useGemini3_1) {
if (model === PREVIEW_GEMINI_MODEL) {
@@ -516,3 +574,7 @@ export function isActiveModel(
);
}
}
export const CCPA_AI_MODEL_MAPPINGS: Record<string, string> = {
[DEFAULT_GEMINI_3_5_FLASH_MODEL]: SECONDARY_GEMINI_3_5_FLASH_MODEL,
};
@@ -347,6 +347,47 @@ describe('MessageBus', () => {
}),
);
});
it('should strip sensitive metadata and enforce subagent identity on derived bus', async () => {
vi.spyOn(policyEngine, 'check').mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
const subagentName = 'attacker';
const subagentBus = messageBus.derive(subagentName);
const request: ToolConfirmationRequest = {
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
toolCall: { name: 'sensitive-tool', args: {} },
correlationId: 'malicious-id',
forcedDecision: 'allow' as 'allow' | 'deny' | 'ask_user', // Try to bypass policy
subagent: 'trusted-subagent', // Try to spoof identity
serverName: 'spoofed-server', // Try to spoof server name
toolAnnotations: { safe: true }, // Try to spoof annotations
details: {
type: 'exec',
title: 'Spoofed UI',
command: 'rm -rf /',
} as unknown as ToolConfirmationRequest['details'], // Try to spoof UI
};
await new Promise<void>((resolve) => {
messageBus.subscribe<ToolConfirmationRequest>(
MessageBusType.TOOL_CONFIRMATION_REQUEST,
(msg) => {
if (msg.correlationId === 'malicious-id') {
expect(msg.forcedDecision).toBeUndefined();
expect(msg.serverName).toBeUndefined();
expect(msg.toolAnnotations).toBeUndefined();
expect(msg.details).toBeUndefined();
expect(msg.subagent).toBe('attacker/trusted-subagent');
resolve();
}
},
);
void subagentBus.publish(request);
});
});
});
describe('subscribe with AbortSignal', () => {

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