mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 809de1fbbf | |||
| 4523560278 | |||
| f08b4af654 | |||
| 8e99c26dd8 | |||
| 0567b25a26 | |||
| f40498db64 | |||
| 4196596f7f | |||
| dceb2ea306 | |||
| e4315b36eb | |||
| d2cd12a7cb | |||
| ae87e208ac | |||
| cfcecebe80 | |||
| 5110bdf56c | |||
| 665228e983 | |||
| 013914071c | |||
| 211e7d1aec | |||
| b77beba13a | |||
| c82e2b5976 | |||
| bd53951dc8 | |||
| 5cac7c10fa | |||
| 41c9260cae | |||
| 8b56d27901 | |||
| 85563dabe8 | |||
| 4a5d5cfc9f | |||
| 630ecc21b9 | |||
| 3cc7e5b096 | |||
| a00d03efc6 | |||
| 980a2574df | |||
| 5188601de0 | |||
| e6f92d66f6 | |||
| d1fa323cfb | |||
| ba04e99bea | |||
| 6afb109953 | |||
| 854f811be0 | |||
| c4d0e3ca36 | |||
| df1d1119c3 | |||
| 50c2e6764f | |||
| 906f8a3151 | |||
| 64cb88d50e | |||
| 96903d50a1 | |||
| 5c4420cc27 | |||
| f79d5e059c | |||
| d7384c446f | |||
| e440e02866 | |||
| c854b60f75 | |||
| 99ae4d8b81 | |||
| aebdba6565 | |||
| 26f8c0f65e | |||
| 29481a1562 | |||
| 2c85f57402 | |||
| 124539b5cc | |||
| ec4910f0bb | |||
| 57c42a5c40 | |||
| 8997488ea6 | |||
| 6589cdf11b | |||
| 9de4289287 | |||
| 37f3a4c90a | |||
| fcc8c62b8b | |||
| c4758ba820 | |||
| 96a8d1d069 | |||
| 3494fda2cf |
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -23,3 +23,4 @@ Thumbs.db
|
||||
**/SKILL.md
|
||||
packages/sdk/test-data/*.json
|
||||
*.mdx
|
||||
packages/vscode-ide-companion/NOTICES.txt
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Generated
+9
-16
@@ -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
@@ -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,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 = {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}**`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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? That’s 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',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user