mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 830f74f3fb | |||
| 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 | |||
| 1a024f30a3 | |||
| 5650fa90d7 | |||
| 85566a73f6 | |||
| 7478859502 | |||
| f09d45d133 | |||
| 792654c88b |
@@ -87,10 +87,50 @@ module.exports = async ({ github, context, core }) => {
|
||||
|
||||
let labelsToAdd = entry.labels_to_add || [];
|
||||
let labelsToRemove = entry.labels_to_remove || [];
|
||||
let existingLabels = [];
|
||||
|
||||
// Fetch existing labels early
|
||||
try {
|
||||
const { data: issueData } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
existingLabels = issueData.labels.map((l) =>
|
||||
typeof l === 'string' ? l : l.name,
|
||||
);
|
||||
} catch (e) {
|
||||
core.warning(
|
||||
`Failed to fetch existing labels for #${issueNumber}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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')) {
|
||||
if (
|
||||
labelsToAdd.includes('status/manual-triage') ||
|
||||
existingLabels.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
|
||||
@@ -105,48 +145,24 @@ module.exports = async ({ github, context, core }) => {
|
||||
labelsToRemove = [...new Set(labelsToRemove)];
|
||||
|
||||
// Fetch existing labels to auto-resolve conflicts
|
||||
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) =>
|
||||
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 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}`,
|
||||
);
|
||||
const hasNewKind = labelsToAdd.some((l) => l.startsWith('kind/'));
|
||||
if (hasNewKind) {
|
||||
const existingKinds = existingLabels.filter((l) => l.startsWith('kind/'));
|
||||
labelsToRemove.push(...existingKinds);
|
||||
}
|
||||
|
||||
// Enforce mutually exclusive area labels
|
||||
@@ -175,6 +191,13 @@ module.exports = async ({ github, context, core }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Re-deduplicate and filter out labels we are trying to add,
|
||||
// and filter out labels that are already present or absent to avoid unnecessary API calls
|
||||
labelsToRemove = [...new Set(labelsToRemove)].filter(
|
||||
(l) => !labelsToAdd.includes(l) && existingLabels.includes(l),
|
||||
);
|
||||
labelsToAdd = labelsToAdd.filter((l) => !existingLabels.includes(l));
|
||||
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -211,25 +234,36 @@ module.exports = async ({ github, context, core }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(entry.explanation && process.env.SUPPRESS_COMMENT !== 'true') ||
|
||||
entry.effort_analysis
|
||||
) {
|
||||
// Restrictive Commenting Policy:
|
||||
// - Silence standard triage (Area/Kind/Priority) to avoid spam.
|
||||
// - Only comment if status/need-information is added (to explain what is missing).
|
||||
// - Only comment if effort_analysis is present (deep technical dive).
|
||||
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') {
|
||||
if (needsInfoAdded && entry.explanation) {
|
||||
commentBody += entry.explanation;
|
||||
}
|
||||
if (entry.effort_analysis) {
|
||||
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 (commentBody) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
core.info(
|
||||
`Posted required comment (need-info or effort) for #${issueNumber}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -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: |-
|
||||
|
||||
@@ -62,12 +62,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 +89,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
|
||||
@@ -158,6 +166,7 @@ jobs:
|
||||
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 +183,7 @@ jobs:
|
||||
"read_file"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"target": "gcp"
|
||||
}
|
||||
}
|
||||
@@ -188,12 +197,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 +222,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"), or in the output of the `/about` command.
|
||||
- If the version is provided but is more than 6 minor versions older than the most recent release, 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. 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.
|
||||
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 +240,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:
|
||||
@@ -275,6 +287,7 @@ jobs:
|
||||
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 }}'
|
||||
@@ -293,7 +306,7 @@ jobs:
|
||||
"read_file"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"target": "gcp"
|
||||
}
|
||||
}
|
||||
@@ -428,9 +441,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: |-
|
||||
|
||||
@@ -23,3 +23,4 @@ Thumbs.db
|
||||
**/SKILL.md
|
||||
packages/sdk/test-data/*.json
|
||||
*.mdx
|
||||
packages/vscode-ide-companion/NOTICES.txt
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.43.0-preview.0
|
||||
# Preview release: v0.43.0-preview.1
|
||||
|
||||
Released: May 12, 2026
|
||||
Released: May 19, 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).
|
||||
@@ -26,6 +26,9 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 85566a7 to release/v0.43.0-preview.0-pr-27073
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#27256](https://github.com/google-gemini/gemini-cli/pull/27256)
|
||||
- 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
|
||||
@@ -193,4 +196,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#26949](https://github.com/google-gemini/gemini-cli/pull/26949)
|
||||
|
||||
**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.42.0-preview.2...v0.43.0-preview.1
|
||||
|
||||
@@ -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
|
||||
@@ -1037,12 +1047,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"auto": {
|
||||
"default": "gemini-3-pro-preview",
|
||||
"contexts": [
|
||||
{
|
||||
"condition": {
|
||||
"releaseChannel": "stable"
|
||||
},
|
||||
"target": "gemini-2.5-pro"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"hasAccessToPreview": false
|
||||
@@ -1186,13 +1190,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"target": "gemini-2.5-pro"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"releaseChannel": "stable",
|
||||
"requestedModels": ["auto"]
|
||||
},
|
||||
"target": "gemini-2.5-pro"
|
||||
},
|
||||
{
|
||||
"condition": {
|
||||
"requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"]
|
||||
|
||||
+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');
|
||||
},
|
||||
});
|
||||
@@ -14,6 +14,18 @@ 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();
|
||||
});
|
||||
@@ -52,7 +64,7 @@ describe('Context Management Fidelity E2E', () => {
|
||||
|
||||
const countTokensResponse: FakeResponse = {
|
||||
method: 'countTokens',
|
||||
response: { totalTokens: 50000 },
|
||||
response: { totalTokens: 1000 },
|
||||
};
|
||||
|
||||
const streamResponse = (text: string): FakeResponse => ({
|
||||
@@ -87,7 +99,6 @@ describe('Context Management Fidelity E2E', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const massivePayload = 'X'.repeat(50000);
|
||||
const traceDir = path.join(rig.testDir!, 'traces');
|
||||
fs.mkdirSync(traceDir, { recursive: true });
|
||||
const traceLog = path.join(traceDir, 'trace.log');
|
||||
@@ -105,24 +116,35 @@ describe('Context Management Fidelity E2E', () => {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
// 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 2: Another turn, resuming Turn 1
|
||||
// Turn 11: Penultimate turn
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
@@ -131,11 +153,11 @@ describe('Context Management Fidelity E2E', () => {
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp2.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 2: ' + massivePayload,
|
||||
stdin: 'Turn 11: ' + generateRandomString(900),
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
// Turn 3: Third turn to force GC, resuming Turn 2
|
||||
// Turn 12: Breach threshold and force GC
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
@@ -144,7 +166,7 @@ describe('Context Management Fidelity E2E', () => {
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp3.json', runMocks),
|
||||
],
|
||||
stdin: 'Turn 3: ' + massivePayload,
|
||||
stdin: 'Turn 12: ' + generateRandomString(900),
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
@@ -214,12 +236,16 @@ describe('Context Management Fidelity E2E', () => {
|
||||
|
||||
// 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
|
||||
(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.id && t.id.length === 32,
|
||||
(t: HistoryTurn) =>
|
||||
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
|
||||
(t.id && t.id.length === 32),
|
||||
);
|
||||
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
|
||||
syntheticTurns.length,
|
||||
|
||||
Generated
-7
@@ -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",
|
||||
@@ -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",
|
||||
|
||||
@@ -93,10 +93,16 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
taskId: string,
|
||||
): Promise<Config> {
|
||||
const workspaceRoot = setTargetDir(agentSettings);
|
||||
const isTrusted = agentSettings.isTrusted ?? false;
|
||||
loadEnvironment(); // Will override any global env with workspace envs
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
const settings = loadSettings(workspaceRoot, isTrusted);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
return loadConfig(settings, new SimpleExtensionLoader(extensions), taskId);
|
||||
return loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
taskId,
|
||||
isTrusted,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
isHeadlessMode,
|
||||
FatalAuthenticationError,
|
||||
PolicyDecision,
|
||||
ApprovalMode,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
createPolicyEngineConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -53,6 +55,32 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
isHeadlessMode: vi.fn().mockReturnValue(false),
|
||||
getCodeAssistServer: vi.fn(),
|
||||
fetchAdminControlsOnce: vi.fn(),
|
||||
createPolicyEngineConfig: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(_settings, mode, _defaultPoliciesDir, _interactive) => ({
|
||||
rules:
|
||||
mode === actual.ApprovalMode.YOLO
|
||||
? [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: actual.PolicyDecision.ALLOW,
|
||||
priority: actual.PRIORITY_YOLO_ALLOW_ALL,
|
||||
modes: [actual.ApprovalMode.YOLO],
|
||||
allowRedirection: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
toolName: 'read_file',
|
||||
decision: actual.PolicyDecision.ALLOW,
|
||||
priority: 1.05,
|
||||
source: 'Default: read-only.toml',
|
||||
},
|
||||
],
|
||||
checkers: [],
|
||||
}),
|
||||
),
|
||||
coreEvents: {
|
||||
emitAdminSettingsChanged: vi.fn(),
|
||||
},
|
||||
@@ -261,6 +289,85 @@ describe('loadConfig', () => {
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
|
||||
});
|
||||
|
||||
describe('policy engine configuration', () => {
|
||||
it('should merge V1 and V2 tool settings into policySettings', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['v1-allowed'],
|
||||
tools: {
|
||||
allowed: ['v2-allowed'],
|
||||
exclude: ['v2-exclude'],
|
||||
core: ['v2-core'],
|
||||
},
|
||||
mcpServers: {
|
||||
test: { command: 'test', args: [] },
|
||||
},
|
||||
policyPaths: ['/path/to/policy'],
|
||||
adminPolicyPaths: ['/path/to/admin/policy'],
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: {
|
||||
core: ['v2-core'],
|
||||
exclude: ['v2-exclude'],
|
||||
allowed: ['v1-allowed'],
|
||||
},
|
||||
mcpServers: settings.mcpServers,
|
||||
policyPaths: settings.policyPaths,
|
||||
adminPolicyPaths: settings.adminPolicyPaths,
|
||||
}),
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use V2 tool settings when V1 is missing', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
allowed: ['v2-allowed'],
|
||||
},
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
allowed: ['v2-allowed'],
|
||||
}),
|
||||
}),
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use V1 tool settings when V2 is also present', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['v1-allowed'],
|
||||
tools: {
|
||||
allowed: ['v2-allowed'],
|
||||
},
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
allowed: ['v1-allowed'],
|
||||
}),
|
||||
}),
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool configuration', () => {
|
||||
it('should pass V1 allowedTools to Config properly', async () => {
|
||||
const settings: Settings = {
|
||||
@@ -385,14 +492,19 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => {
|
||||
it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
|
||||
vi.stubEnv('GEMINI_YOLO_MODE', 'false');
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approvalMode: 'default',
|
||||
policyEngineConfig: expect.objectContaining({
|
||||
rules: [],
|
||||
rules: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
toolName: 'read_file',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
ExperimentFlags,
|
||||
isHeadlessMode,
|
||||
FatalAuthenticationError,
|
||||
PolicyDecision,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
createPolicyEngineConfig,
|
||||
type PolicySettings,
|
||||
type TelemetryTarget,
|
||||
type ConfigParameters,
|
||||
type ExtensionLoader,
|
||||
@@ -38,6 +38,7 @@ export async function loadConfig(
|
||||
settings: Settings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
taskId: string,
|
||||
trusted: boolean = false,
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
|
||||
@@ -63,6 +64,24 @@ export async function loadConfig(
|
||||
? ApprovalMode.YOLO
|
||||
: ApprovalMode.DEFAULT;
|
||||
|
||||
const policySettings: PolicySettings = {
|
||||
mcpServers: settings.mcpServers,
|
||||
tools: {
|
||||
core: settings.coreTools || settings.tools?.core,
|
||||
exclude: settings.excludeTools || settings.tools?.exclude,
|
||||
allowed: settings.allowedTools || settings.tools?.allowed,
|
||||
},
|
||||
policyPaths: settings.policyPaths,
|
||||
adminPolicyPaths: settings.adminPolicyPaths,
|
||||
};
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
policySettings,
|
||||
approvalMode,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: taskId,
|
||||
clientName: 'a2a-server',
|
||||
@@ -78,20 +97,7 @@ export async function loadConfig(
|
||||
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
|
||||
showMemoryUsage: settings.showMemoryUsage || false,
|
||||
approvalMode,
|
||||
policyEngineConfig: {
|
||||
rules:
|
||||
approvalMode === ApprovalMode.YOLO
|
||||
? [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_YOLO_ALLOW_ALL,
|
||||
modes: [ApprovalMode.YOLO],
|
||||
allowRedirection: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
policyEngineConfig,
|
||||
mcpServers: settings.mcpServers,
|
||||
cwd: workspaceDir,
|
||||
telemetry: {
|
||||
@@ -118,7 +124,7 @@ export async function loadConfig(
|
||||
},
|
||||
ideMode: false,
|
||||
folderTrust,
|
||||
trustedFolder: true,
|
||||
trustedFolder: trusted,
|
||||
extensionLoader,
|
||||
checkpointing,
|
||||
interactive: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const suffix = Math.random().toString(36).slice(2);
|
||||
@@ -40,6 +40,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
},
|
||||
getErrorMessage: (error: unknown) => String(error),
|
||||
homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
|
||||
checkPathTrust: vi.fn(() => ({ isTrusted: false })),
|
||||
isHeadlessMode: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -146,7 +148,7 @@ describe('loadSettings', () => {
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
const result = loadSettings(mockWorkspaceDir, true);
|
||||
// Primitive value overwritten
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
|
||||
@@ -154,4 +156,78 @@ describe('loadSettings', () => {
|
||||
expect(result.fileFiltering?.respectGitIgnore).toBe(false);
|
||||
expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('security', () => {
|
||||
it('should NOT load workspace settings if workspace is NOT trusted', () => {
|
||||
const userSettings = { showMemoryUsage: false };
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = { showMemoryUsage: true };
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
workspaceSettingsPath,
|
||||
JSON.stringify(workspaceSettings),
|
||||
);
|
||||
|
||||
// checkPathTrust is mocked to return isTrusted: false by default
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.showMemoryUsage).toBe(false);
|
||||
});
|
||||
|
||||
it('should load workspace settings if workspace IS trusted', () => {
|
||||
vi.mocked(checkPathTrust).mockReturnValueOnce({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
const userSettings = { showMemoryUsage: false };
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = { showMemoryUsage: true };
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
workspaceSettingsPath,
|
||||
JSON.stringify(workspaceSettings),
|
||||
);
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => {
|
||||
vi.mocked(checkPathTrust).mockReturnValueOnce({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
const userSettings = {
|
||||
adminPolicyPaths: ['/trusted/admin'],
|
||||
policyPaths: ['/trusted/user'],
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = {
|
||||
adminPolicyPaths: ['./malicious/admin'],
|
||||
policyPaths: ['./malicious/user'],
|
||||
showMemoryUsage: true,
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
workspaceSettingsPath,
|
||||
JSON.stringify(workspaceSettings),
|
||||
);
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
expect(result.adminPolicyPaths).toEqual(['/trusted/admin']);
|
||||
expect(result.policyPaths).toEqual(['/trusted/user']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
getErrorMessage,
|
||||
type TelemetrySettings,
|
||||
homedir,
|
||||
checkPathTrust,
|
||||
isHeadlessMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
@@ -51,6 +53,8 @@ export interface Settings {
|
||||
experimental?: {
|
||||
enableAgents?: boolean;
|
||||
};
|
||||
policyPaths?: string[];
|
||||
adminPolicyPaths?: string[];
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
@@ -64,13 +68,16 @@ export interface CheckpointingSettings {
|
||||
|
||||
/**
|
||||
* Loads settings from user and workspace directories.
|
||||
* Project settings override user settings.
|
||||
* Project settings override user settings if the workspace is trusted.
|
||||
*
|
||||
* How is it different to gemini-cli/cli: Returns already merged settings rather
|
||||
* than `LoadedSettings` (unnecessary since we are not modifying users
|
||||
* settings.json).
|
||||
*/
|
||||
export function loadSettings(workspaceDir: string): Settings {
|
||||
export function loadSettings(
|
||||
workspaceDir: string,
|
||||
isTrustedOverride?: boolean,
|
||||
): Settings {
|
||||
let userSettings: Settings = {};
|
||||
let workspaceSettings: Settings = {};
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
@@ -92,27 +99,39 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
});
|
||||
}
|
||||
|
||||
let isTrusted = isTrustedOverride;
|
||||
if (isTrusted === undefined) {
|
||||
const { isTrusted: trustResult } = checkPathTrust({
|
||||
path: workspaceDir,
|
||||
isFolderTrustEnabled: userSettings.folderTrust ?? true,
|
||||
isHeadless: isHeadlessMode(),
|
||||
});
|
||||
isTrusted = trustResult ?? false;
|
||||
}
|
||||
|
||||
const workspaceSettingsPath = path.join(
|
||||
workspaceDir,
|
||||
GEMINI_DIR,
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
// Load workspace settings
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
// Load workspace settings only if trusted
|
||||
if (isTrusted) {
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: workspaceSettingsPath,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: workspaceSettingsPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsErrors.length > 0) {
|
||||
@@ -125,10 +144,18 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
|
||||
// If there are overlapping keys, the values of workspaceSettings will
|
||||
// override values from userSettings
|
||||
return {
|
||||
const mergedSettings = {
|
||||
...userSettings,
|
||||
...workspaceSettings,
|
||||
};
|
||||
|
||||
// Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
|
||||
// configuration and cannot be overridden by workspace-level settings, even if the
|
||||
// workspace is trusted.
|
||||
mergedSettings.policyPaths = userSettings.policyPaths;
|
||||
mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
|
||||
|
||||
return mergedSettings;
|
||||
}
|
||||
|
||||
function resolveEnvVarsInString(value: string): string {
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
debugLogger,
|
||||
SimpleExtensionLoader,
|
||||
GitService,
|
||||
checkPathTrust,
|
||||
isHeadlessMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
|
||||
@@ -197,12 +199,23 @@ export async function createApp() {
|
||||
// Load the server configuration once on startup.
|
||||
const workspaceRoot = setTargetDir(undefined);
|
||||
loadEnvironment();
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
|
||||
// Use a temporary settings load to check if folder trust is enabled.
|
||||
// This is similar to how the CLI handles the initial trust check.
|
||||
const initialSettings = loadSettings(workspaceRoot, false);
|
||||
const { isTrusted } = checkPathTrust({
|
||||
path: workspaceRoot,
|
||||
isFolderTrustEnabled: initialSettings.folderTrust ?? true,
|
||||
isHeadless: isHeadlessMode(),
|
||||
});
|
||||
|
||||
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
const config = await loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
'a2a-server',
|
||||
isTrusted ?? false,
|
||||
);
|
||||
|
||||
let git: GitService | undefined;
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface AgentSettings {
|
||||
kind: CoderAgentEvent.StateAgentSettingsEvent;
|
||||
workspacePath: string;
|
||||
autoExecute?: boolean;
|
||||
isTrusted?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolCallConfirmation {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
ToolConfirmationOutcome,
|
||||
getChannelFromVersion,
|
||||
getAutoModelDescription,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
@@ -272,8 +271,6 @@ export function buildAvailableModels(
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
const releaseChannel = getChannelFromVersion(config.clientVersion);
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
@@ -284,7 +281,6 @@ export function buildAvailableModels(
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -298,7 +294,10 @@ export function buildAvailableModels(
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_AUTO,
|
||||
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
|
||||
description: getAutoModelDescription(releaseChannel, useGemini31),
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
@@ -609,11 +609,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: '',
|
||||
|
||||
@@ -154,6 +154,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
onEscapePromptChange={uiActions.onEscapePromptChange}
|
||||
focus={isFocused}
|
||||
vimHandleInput={uiActions.vimHandleInput}
|
||||
vimEnabled={vimEnabled}
|
||||
vimMode={vimMode}
|
||||
isEmbeddedShellFocused={uiState.embeddedShellFocused}
|
||||
popAllMessages={uiActions.popAllMessages}
|
||||
onQueueMessage={uiActions.addMessage}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type EditorType,
|
||||
isEditorAvailable,
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
@@ -72,10 +71,6 @@ export function EditorSettingsDialog({
|
||||
)
|
||||
: 0;
|
||||
if (editorIndex === -1) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Editor is not supported: ${currentPreference}`,
|
||||
);
|
||||
editorIndex = 0;
|
||||
}
|
||||
|
||||
@@ -131,10 +126,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 +153,7 @@ export function EditorSettingsDialog({
|
||||
onSelect={handleEditorSelect}
|
||||
isFocused={focusedSection === 'editor'}
|
||||
key={selectedScope}
|
||||
maxItemsToShow={editorItems.length}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -4898,6 +4898,60 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT open shortcuts help with ? in vim NORMAL mode', async () => {
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const vimHandleInput = vi.fn().mockReturnValue(true);
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt
|
||||
{...props}
|
||||
vimEnabled={true}
|
||||
vimMode="NORMAL"
|
||||
vimHandleInput={vimHandleInput}
|
||||
/>,
|
||||
{
|
||||
uiActions: { setShortcutsHelpVisible },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
expect(setShortcutsHelpVisible).not.toHaveBeenCalled();
|
||||
expect(vimHandleInput).toHaveBeenCalled();
|
||||
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should open shortcuts help with ? in vim INSERT mode', async () => {
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const vimHandleInput = vi.fn().mockReturnValue(false);
|
||||
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt
|
||||
{...props}
|
||||
vimEnabled={true}
|
||||
vimMode="INSERT"
|
||||
vimHandleInput={vimHandleInput}
|
||||
/>,
|
||||
{
|
||||
uiActions: { setShortcutsHelpVisible },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'terminal paste event occurs',
|
||||
|
||||
@@ -92,6 +92,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
|
||||
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import type { VimMode } from '../contexts/VimModeContext.js';
|
||||
|
||||
const SCROLLBAR_GUTTER_WIDTH = 1;
|
||||
|
||||
@@ -126,6 +127,8 @@ export interface InputPromptProps {
|
||||
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||||
onSuggestionsVisibilityChange?: (visible: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
vimEnabled?: boolean;
|
||||
vimMode?: VimMode;
|
||||
isEmbeddedShellFocused?: boolean;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
streamingState: StreamingState;
|
||||
@@ -214,6 +217,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onEscapePromptChange,
|
||||
onSuggestionsVisibilityChange,
|
||||
vimHandleInput,
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
isEmbeddedShellFocused,
|
||||
setQueueErrorMessage,
|
||||
streamingState,
|
||||
@@ -859,7 +864,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (shortcutsHelpVisible) {
|
||||
if (key.sequence === '?' && key.insertable) {
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
(!vimEnabled || vimMode === 'INSERT')
|
||||
) {
|
||||
setShortcutsHelpVisible(false);
|
||||
buffer.handleInput(key);
|
||||
return true;
|
||||
@@ -879,7 +888,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
!shortcutsHelpVisible &&
|
||||
buffer.text.length === 0
|
||||
buffer.text.length === 0 &&
|
||||
(!vimEnabled || vimMode === 'INSERT')
|
||||
) {
|
||||
setShortcutsHelpVisible(true);
|
||||
return true;
|
||||
@@ -1374,6 +1384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetCompletionState,
|
||||
resetEscapeState,
|
||||
vimHandleInput,
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
reverseSearchActive,
|
||||
textBeforeReverseSearch,
|
||||
cursorPosition,
|
||||
|
||||
@@ -34,6 +34,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getAutoModelDescription: (
|
||||
hasAccessToPreview: boolean,
|
||||
useGemini3_1?: boolean,
|
||||
) =>
|
||||
`Auto Model Description (preview: ${hasAccessToPreview}, 3.1: ${useGemini3_1})`,
|
||||
getDisplayString: (val: string) => mockGetDisplayString(val),
|
||||
logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
|
||||
mockLogModelSlashCommand(config, event),
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
AuthType,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
isProModel,
|
||||
getChannelFromVersion,
|
||||
getAutoModelDescription,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -66,7 +65,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || GEMINI_MODEL_ALIAS_AUTO;
|
||||
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel() ?? false;
|
||||
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
@@ -122,12 +121,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const releaseChannel = useMemo(
|
||||
() => getChannelFromVersion(config?.clientVersion ?? ''),
|
||||
[config?.clientVersion],
|
||||
);
|
||||
|
||||
const mainOptions = useMemo(() => {
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
@@ -142,7 +135,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
const list = allOptions
|
||||
@@ -170,7 +162,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_AUTO,
|
||||
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
|
||||
description: getAutoModelDescription(releaseChannel, useGemini31),
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
),
|
||||
key: GEMINI_MODEL_ALIAS_AUTO,
|
||||
},
|
||||
{
|
||||
@@ -192,7 +187,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
@@ -209,7 +203,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
return allOptions
|
||||
@@ -302,7 +295,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
config,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -2258,6 +2258,80 @@ describe('useVim hook', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('should handle unmapped keys in Normal mode', () => {
|
||||
type UnmappedKeyCase = {
|
||||
char: string;
|
||||
insertable: boolean;
|
||||
};
|
||||
it.each<UnmappedKeyCase>([
|
||||
{ char: 'm', insertable: true },
|
||||
{ char: 'n', insertable: true },
|
||||
{ char: 'p', insertable: true },
|
||||
{ char: 'q', insertable: true },
|
||||
{ char: 's', insertable: true },
|
||||
{ char: 'v', insertable: true },
|
||||
{ char: 'y', insertable: true },
|
||||
{ char: 'z', insertable: true },
|
||||
{ char: 'H', insertable: true },
|
||||
{ char: 'J', insertable: true },
|
||||
{ char: 'K', insertable: true },
|
||||
{ char: 'L', insertable: true },
|
||||
{ char: 'M', insertable: true },
|
||||
{ char: 'N', insertable: true },
|
||||
{ char: 'P', insertable: true },
|
||||
{ char: 'Q', insertable: true },
|
||||
{ char: 'R', insertable: true },
|
||||
{ char: 'S', insertable: true },
|
||||
{ char: 'U', insertable: true },
|
||||
{ char: 'V', insertable: true },
|
||||
{ char: 'Y', insertable: true },
|
||||
{ char: 'Z', insertable: true },
|
||||
{ char: '/', insertable: true },
|
||||
{ char: '#', insertable: true },
|
||||
{ char: '%', insertable: true },
|
||||
{ char: '&', insertable: true },
|
||||
{ char: "'", insertable: true },
|
||||
{ char: '(', insertable: true },
|
||||
{ char: ')', insertable: true },
|
||||
{ char: '*', insertable: true },
|
||||
{ char: '+', insertable: true },
|
||||
{ char: '-', insertable: true },
|
||||
{ char: '/', insertable: true },
|
||||
{ char: ':', insertable: true },
|
||||
{ char: '<', insertable: true },
|
||||
{ char: '=', insertable: true },
|
||||
{ char: '>', insertable: true },
|
||||
{ char: '@', insertable: true },
|
||||
{ char: '[', insertable: true },
|
||||
{ char: '\\', insertable: true },
|
||||
{ char: ']', insertable: true },
|
||||
{ char: '_', insertable: true },
|
||||
{ char: '`', insertable: true },
|
||||
{ char: '{', insertable: true },
|
||||
{ char: '|', insertable: true },
|
||||
{ char: '}', insertable: true },
|
||||
])(
|
||||
'$char: should be swallowed and do nothing in Normal mode',
|
||||
async ({ char, insertable }) => {
|
||||
const { result } = await renderVimHook();
|
||||
exitInsertMode(result);
|
||||
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.handleInput(
|
||||
createKey({ sequence: char, name: char, insertable }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(mockVimContext.setVimMode).not.toHaveBeenCalledWith('INSERT');
|
||||
|
||||
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
|
||||
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', async () => {
|
||||
it('df{char}: executes delete-to-char, not a dangling operator', async () => {
|
||||
const { result } = await renderVimHook();
|
||||
|
||||
@@ -1486,6 +1486,11 @@ 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) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not handled by vim so allow other handlers to process it.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,7 +119,9 @@ export function getInstallationInfo(
|
||||
// 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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { RemoteSessionInvocation } from './remote-session-invocation.js';
|
||||
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
|
||||
import {
|
||||
type RemoteAgentDefinition,
|
||||
type SubagentProgress,
|
||||
SubagentState,
|
||||
} from './types.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ToolResult } from '../tools/tools.js';
|
||||
import type { AgentEvent } from '../agent/types.js';
|
||||
|
||||
vi.mock('./remote-subagent-protocol.js');
|
||||
|
||||
const mockDefinition: RemoteAgentDefinition = {
|
||||
name: 'test-agent',
|
||||
kind: 'remote',
|
||||
agentCardUrl: 'http://test-agent/card',
|
||||
displayName: 'Test Agent',
|
||||
description: 'A test agent',
|
||||
inputConfig: { inputSchema: { type: 'object' } },
|
||||
};
|
||||
|
||||
const mockMessageBus = createMockMessageBus();
|
||||
|
||||
interface MockSessionSetupOptions {
|
||||
result?: ToolResult;
|
||||
error?: Error;
|
||||
progress?: SubagentProgress;
|
||||
sessionState?: { contextId?: string; taskId?: string };
|
||||
}
|
||||
|
||||
function setupMockSession(options: MockSessionSetupOptions = {}) {
|
||||
const {
|
||||
result = {
|
||||
llmContent: [{ text: 'done' }],
|
||||
returnDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'Test Agent',
|
||||
state: SubagentState.COMPLETED,
|
||||
result: 'done',
|
||||
recentActivity: [],
|
||||
} satisfies SubagentProgress,
|
||||
},
|
||||
error,
|
||||
progress,
|
||||
sessionState = {},
|
||||
} = options;
|
||||
|
||||
const subscriberCallbacks: Array<(event: AgentEvent) => void> = [];
|
||||
|
||||
const mockSession = {
|
||||
send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
|
||||
getResult: error
|
||||
? vi.fn().mockRejectedValue(error)
|
||||
: vi.fn().mockResolvedValue(result),
|
||||
getLatestProgress: vi.fn().mockReturnValue(progress),
|
||||
getSessionState: vi.fn().mockReturnValue(sessionState),
|
||||
subscribe: vi.fn((cb: (event: AgentEvent) => void) => {
|
||||
subscriberCallbacks.push(cb);
|
||||
return vi.fn(); // unsubscribe
|
||||
}),
|
||||
abort: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(RemoteSubagentSession).mockImplementation(
|
||||
() => mockSession as unknown as RemoteSubagentSession,
|
||||
);
|
||||
|
||||
return {
|
||||
mockSession,
|
||||
subscriberCallbacks,
|
||||
/** Fire a message event through all subscribed callbacks. */
|
||||
emitEvent(event: AgentEvent) {
|
||||
for (const cb of subscriberCallbacks) {
|
||||
cb(event);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('RemoteSessionInvocation', () => {
|
||||
let mockContext: AgentLoopContext;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const mockConfig = {
|
||||
getA2AClientManager: vi.fn().mockReturnValue({}),
|
||||
injectionService: {
|
||||
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
mockContext = { config: mockConfig } as unknown as AgentLoopContext;
|
||||
|
||||
// Clear the static sessionState map between tests
|
||||
(
|
||||
RemoteSessionInvocation as unknown as {
|
||||
sessionState?: Map<string, unknown>;
|
||||
}
|
||||
).sessionState?.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constructor Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Constructor Validation', () => {
|
||||
it('accepts valid input with string query', () => {
|
||||
expect(() => {
|
||||
new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hello' },
|
||||
mockMessageBus,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts missing query (defaults to "Get Started!")', () => {
|
||||
expect(() => {
|
||||
new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{},
|
||||
mockMessageBus,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws if query is not a string', () => {
|
||||
expect(() => {
|
||||
new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 123 },
|
||||
mockMessageBus,
|
||||
);
|
||||
}).toThrow("requires a string 'query' input");
|
||||
});
|
||||
|
||||
it('throws if A2AClientManager is not available', () => {
|
||||
const noA2AConfig = {
|
||||
getA2AClientManager: vi.fn().mockReturnValue(undefined),
|
||||
injectionService: {
|
||||
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
|
||||
},
|
||||
} as unknown as Config;
|
||||
const noA2AContext = {
|
||||
config: noA2AConfig,
|
||||
} as unknown as AgentLoopContext;
|
||||
|
||||
expect(() => {
|
||||
new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
noA2AContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
}).toThrow('A2AClientManager is not available');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execution Logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Execution Logic', () => {
|
||||
it('should create session and return result', async () => {
|
||||
const completedProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'Test Agent',
|
||||
state: SubagentState.COMPLETED,
|
||||
result: 'Agent output',
|
||||
recentActivity: [],
|
||||
};
|
||||
const expectedResult: ToolResult = {
|
||||
llmContent: [{ text: 'Agent output' }],
|
||||
returnDisplay: completedProgress,
|
||||
};
|
||||
|
||||
setupMockSession({
|
||||
result: expectedResult,
|
||||
progress: completedProgress,
|
||||
});
|
||||
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'do stuff' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(RemoteSubagentSession).toHaveBeenCalledOnce();
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should pass initial state from static map to session', async () => {
|
||||
const priorState = { contextId: 'ctx-42', taskId: 'task-42' };
|
||||
|
||||
// Seed the static map before constructing the invocation
|
||||
(
|
||||
RemoteSessionInvocation as unknown as {
|
||||
sessionState: Map<string, unknown>;
|
||||
}
|
||||
).sessionState.set('test-agent::http://test-agent/card', priorState);
|
||||
|
||||
setupMockSession();
|
||||
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// Verify the session constructor received the prior state
|
||||
expect(RemoteSubagentSession).toHaveBeenCalledWith(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
mockMessageBus,
|
||||
priorState,
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist session state in finally block', async () => {
|
||||
const newState = { contextId: 'ctx-new', taskId: 'task-new' };
|
||||
setupMockSession({ sessionState: newState });
|
||||
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// Verify the state was persisted in the static map
|
||||
const storedState = (
|
||||
RemoteSessionInvocation as unknown as {
|
||||
sessionState: Map<string, { contextId?: string; taskId?: string }>;
|
||||
}
|
||||
).sessionState.get('test-agent::http://test-agent/card');
|
||||
expect(storedState).toEqual(newState);
|
||||
});
|
||||
|
||||
it('should persist session state across invocations', async () => {
|
||||
// First invocation returns state
|
||||
const firstState = { contextId: 'ctx-1', taskId: 'task-1' };
|
||||
setupMockSession({ sessionState: firstState });
|
||||
|
||||
const invocation1 = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'first' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation1.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// Second invocation — the mock constructor should receive firstState
|
||||
const secondState = { contextId: 'ctx-2', taskId: 'task-2' };
|
||||
setupMockSession({ sessionState: secondState });
|
||||
|
||||
const invocation2 = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'second' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invocation2.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// The second invocation should have received the first's state
|
||||
const secondCallArgs = vi.mocked(RemoteSubagentSession).mock.calls[1];
|
||||
expect(secondCallArgs[3]).toEqual(firstState);
|
||||
});
|
||||
|
||||
it('should subscribe for progress updates', async () => {
|
||||
const completedProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'Test Agent',
|
||||
state: SubagentState.RUNNING,
|
||||
result: 'partial',
|
||||
recentActivity: [],
|
||||
};
|
||||
const { mockSession, emitEvent } = setupMockSession({
|
||||
progress: completedProgress,
|
||||
});
|
||||
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
// Override getResult to emit a message event mid-execution
|
||||
mockSession.getResult.mockImplementation(async () => {
|
||||
emitEvent({
|
||||
type: 'message',
|
||||
id: 'e1',
|
||||
timestamp: new Date().toISOString(),
|
||||
streamId: 's1',
|
||||
role: 'agent',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
});
|
||||
return {
|
||||
llmContent: [{ text: 'done' }],
|
||||
returnDisplay: completedProgress,
|
||||
};
|
||||
});
|
||||
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
// subscribe should have been called (at least once for progress, possibly for parent)
|
||||
expect(mockSession.subscribe).toHaveBeenCalled();
|
||||
// updateOutput should have been called with the progress from getLatestProgress
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle abort gracefully', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const partialProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'Test Agent',
|
||||
state: SubagentState.RUNNING,
|
||||
result: '',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'thought',
|
||||
content: 'Thinking...',
|
||||
status: SubagentState.RUNNING,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { mockSession } = setupMockSession({ progress: partialProgress });
|
||||
|
||||
// When getResult resolves, the signal will already be aborted
|
||||
mockSession.getResult.mockImplementation(async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
llmContent: [{ text: '' }],
|
||||
returnDisplay: '',
|
||||
};
|
||||
});
|
||||
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const result = await invocation.execute({
|
||||
abortSignal: controller.signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'cancelled' });
|
||||
expect(
|
||||
(result.returnDisplay as SubagentProgress).recentActivity[0].status,
|
||||
).toBe(SubagentState.CANCELLED);
|
||||
expect(result.llmContent).toEqual([
|
||||
{ text: 'Operation cancelled by user' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle execution errors gracefully', async () => {
|
||||
setupMockSession({ error: new Error('Network failure') });
|
||||
|
||||
const updateOutput = vi.fn();
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
updateOutput,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
'Network failure',
|
||||
);
|
||||
// updateOutput should be called with error progress
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: 'error' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include partial output in error display', async () => {
|
||||
const partialProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'Test Agent',
|
||||
state: SubagentState.RUNNING,
|
||||
result: 'Partial work so far',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'thought',
|
||||
content: 'Thinking...',
|
||||
status: SubagentState.RUNNING,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
setupMockSession({
|
||||
error: new Error('mid-stream error'),
|
||||
progress: partialProgress,
|
||||
});
|
||||
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
const result = await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
// Should contain both the partial output and the error
|
||||
expect(display.result).toContain('Partial work so far');
|
||||
expect(display.result).toContain('mid-stream error');
|
||||
// Should preserve and update partial activity status to ERROR
|
||||
expect(display.recentActivity).toHaveLength(1);
|
||||
expect(display.recentActivity[0].content).toBe('Thinking...');
|
||||
expect(display.recentActivity[0].status).toBe(SubagentState.ERROR);
|
||||
});
|
||||
|
||||
it('should clean up listeners in finally', async () => {
|
||||
const { mockSession } = setupMockSession();
|
||||
|
||||
const controller = new AbortController();
|
||||
const removeEventListenerSpy = vi.spyOn(
|
||||
controller.signal,
|
||||
'removeEventListener',
|
||||
);
|
||||
|
||||
const onAgentEvent = vi.fn();
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
{ onAgentEvent },
|
||||
);
|
||||
|
||||
await invocation.execute({
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
// removeEventListener should have been called for the abort listener
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
'abort',
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// All unsubscribe functions returned by subscribe during execute should be called
|
||||
const postExecuteUnsubscribes = mockSession.subscribe.mock.results.map(
|
||||
(r) => r.value,
|
||||
);
|
||||
for (const unsub of postExecuteUnsubscribes) {
|
||||
expect(unsub).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionState Management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('SessionState Management', () => {
|
||||
it('should use composite name::url as session state key', async () => {
|
||||
const secondDefinition: RemoteAgentDefinition = {
|
||||
...mockDefinition,
|
||||
name: 'other-agent',
|
||||
displayName: 'Other Agent',
|
||||
agentCardUrl: 'http://other-agent/card',
|
||||
};
|
||||
|
||||
// First agent
|
||||
setupMockSession({
|
||||
sessionState: { contextId: 'ctx-a' },
|
||||
});
|
||||
const inv1 = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await inv1.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// Second agent
|
||||
setupMockSession({
|
||||
sessionState: { contextId: 'ctx-b' },
|
||||
});
|
||||
const inv2 = new RemoteSessionInvocation(
|
||||
secondDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await inv2.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
const stateMap = (
|
||||
RemoteSessionInvocation as unknown as {
|
||||
sessionState: Map<string, { contextId?: string; taskId?: string }>;
|
||||
}
|
||||
).sessionState;
|
||||
|
||||
// Each agent should have its own entry keyed by name::url
|
||||
expect(stateMap.get('test-agent::http://test-agent/card')).toEqual({
|
||||
contextId: 'ctx-a',
|
||||
});
|
||||
expect(stateMap.get('other-agent::http://other-agent/card')).toEqual({
|
||||
contextId: 'ctx-b',
|
||||
});
|
||||
});
|
||||
|
||||
it('should isolate same-name agents with different URLs', async () => {
|
||||
const defA: RemoteAgentDefinition = {
|
||||
...mockDefinition,
|
||||
agentCardUrl: 'http://host-a/card',
|
||||
};
|
||||
const defB: RemoteAgentDefinition = {
|
||||
...mockDefinition,
|
||||
agentCardUrl: 'http://host-b/card',
|
||||
};
|
||||
|
||||
// Agent A
|
||||
setupMockSession({ sessionState: { contextId: 'ctx-a' } });
|
||||
const invA = new RemoteSessionInvocation(
|
||||
defA,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invA.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
// Agent B (same name, different URL)
|
||||
setupMockSession({ sessionState: { contextId: 'ctx-b' } });
|
||||
const invB = new RemoteSessionInvocation(
|
||||
defB,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await invB.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
const stateMap = (
|
||||
RemoteSessionInvocation as unknown as {
|
||||
sessionState: Map<string, { contextId?: string; taskId?: string }>;
|
||||
}
|
||||
).sessionState;
|
||||
|
||||
expect(stateMap.get('test-agent::http://host-a/card')).toEqual({
|
||||
contextId: 'ctx-a',
|
||||
});
|
||||
expect(stateMap.get('test-agent::http://host-b/card')).toEqual({
|
||||
contextId: 'ctx-b',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to name-only key when URL is unavailable', async () => {
|
||||
const noUrlDef: RemoteAgentDefinition = {
|
||||
...mockDefinition,
|
||||
agentCardUrl: undefined,
|
||||
};
|
||||
|
||||
setupMockSession({ sessionState: { contextId: 'ctx-no-url' } });
|
||||
const inv = new RemoteSessionInvocation(
|
||||
noUrlDef,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
await inv.execute({ abortSignal: new AbortController().signal });
|
||||
|
||||
const stateMap = (
|
||||
RemoteSessionInvocation as unknown as {
|
||||
sessionState: Map<string, { contextId?: string; taskId?: string }>;
|
||||
}
|
||||
).sessionState;
|
||||
|
||||
expect(stateMap.get('test-agent')).toEqual({ contextId: 'ctx-no-url' });
|
||||
});
|
||||
|
||||
it('should persist state even on error', async () => {
|
||||
const stateOnError = { contextId: 'ctx-err', taskId: 'task-err' };
|
||||
setupMockSession({
|
||||
error: new Error('boom'),
|
||||
sessionState: stateOnError,
|
||||
});
|
||||
|
||||
const invocation = new RemoteSessionInvocation(
|
||||
mockDefinition,
|
||||
mockContext,
|
||||
{ query: 'hi' },
|
||||
mockMessageBus,
|
||||
);
|
||||
|
||||
await invocation.execute({
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
const stateMap = (
|
||||
RemoteSessionInvocation as unknown as {
|
||||
sessionState: Map<string, { contextId?: string; taskId?: string }>;
|
||||
}
|
||||
).sessionState;
|
||||
|
||||
expect(stateMap.get('test-agent::http://test-agent/card')).toEqual(
|
||||
stateOnError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseToolInvocation,
|
||||
type ToolConfirmationOutcome,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type ExecuteOptions,
|
||||
} from '../tools/tools.js';
|
||||
import {
|
||||
DEFAULT_QUERY_STRING,
|
||||
type RemoteAgentInputs,
|
||||
type RemoteAgentDefinition,
|
||||
type AgentInputs,
|
||||
type SubagentProgress,
|
||||
type SubagentActivityItem,
|
||||
SubagentState,
|
||||
getRemoteAgentTargetUrl,
|
||||
} from './types.js';
|
||||
import { type AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { A2AAgentError } from './a2a-errors.js';
|
||||
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
|
||||
import type { AgentEvent } from '../agent/types.js';
|
||||
|
||||
/** Optional configuration for remote agent invocations. */
|
||||
export interface SubagentInvocationOptions {
|
||||
toolName?: string;
|
||||
toolDisplayName?: string;
|
||||
onAgentEvent?: (event: AgentEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-based remote agent invocation.
|
||||
*
|
||||
* This implementation delegates execution to {@link RemoteSubagentSession},
|
||||
* which wraps the A2A client streaming behind the AgentProtocol interface.
|
||||
*
|
||||
* Cross-invocation A2A session state (contextId/taskId) is persisted via a
|
||||
* static map keyed by a composite of agent name and target URL. This ensures
|
||||
* agents with the same name but different endpoints maintain independent state.
|
||||
*/
|
||||
export class RemoteSessionInvocation extends BaseToolInvocation<
|
||||
RemoteAgentInputs,
|
||||
ToolResult
|
||||
> {
|
||||
// Persist A2A conversation state across ephemeral invocation instances.
|
||||
// Keyed by composite of name + target URL so agents with the same name
|
||||
// but different endpoints don't share state.
|
||||
private static readonly sessionState = new Map<
|
||||
string,
|
||||
{ contextId?: string; taskId?: string }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Builds a composite key for the sessionState map.
|
||||
* Format: `name::targetUrl` (or just `name` if no URL can be derived).
|
||||
*/
|
||||
private static sessionKey(definition: RemoteAgentDefinition): string {
|
||||
const url = getRemoteAgentTargetUrl(definition);
|
||||
return url ? `${definition.name}::${url}` : definition.name;
|
||||
}
|
||||
|
||||
private readonly _onAgentEvent?: (event: AgentEvent) => void;
|
||||
|
||||
constructor(
|
||||
private readonly definition: RemoteAgentDefinition,
|
||||
private readonly context: AgentLoopContext,
|
||||
params: AgentInputs,
|
||||
messageBus: MessageBus,
|
||||
options?: SubagentInvocationOptions,
|
||||
) {
|
||||
const query = params['query'] ?? DEFAULT_QUERY_STRING;
|
||||
if (typeof query !== 'string') {
|
||||
throw new Error(
|
||||
`Remote agent '${definition.name}' requires a string 'query' input.`,
|
||||
);
|
||||
}
|
||||
// Safe to pass strict object to super
|
||||
super(
|
||||
{ query },
|
||||
messageBus,
|
||||
options?.toolName ?? definition.name,
|
||||
options?.toolDisplayName ?? definition.displayName,
|
||||
);
|
||||
this._onAgentEvent = options?.onAgentEvent;
|
||||
|
||||
// Validate that A2AClientManager is available at construction time
|
||||
if (!this.context.config.getA2AClientManager()) {
|
||||
throw new Error(
|
||||
`Failed to initialize RemoteSessionInvocation for '${definition.name}': A2AClientManager is not available.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Calling remote agent ${this.definition.displayName ?? this.definition.name}`;
|
||||
}
|
||||
|
||||
protected override async getConfirmationDetails(
|
||||
_abortSignal: AbortSignal,
|
||||
): Promise<ToolCallConfirmationDetails | false> {
|
||||
return {
|
||||
type: 'info',
|
||||
title: `Call Remote Agent: ${this.definition.displayName ?? this.definition.name}`,
|
||||
prompt: `Calling remote agent: "${this.params.query}"`,
|
||||
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
|
||||
// Policy updates are now handled centrally by the scheduler
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async execute(options: ExecuteOptions): Promise<ToolResult> {
|
||||
const { abortSignal: _signal, updateOutput } = options;
|
||||
const agentName = this.definition.displayName ?? this.definition.name;
|
||||
const emptyActivity: SubagentActivityItem[] = [];
|
||||
|
||||
// Seed session with prior A2A conversation state
|
||||
const stateKey = RemoteSessionInvocation.sessionKey(this.definition);
|
||||
const priorState = RemoteSessionInvocation.sessionState.get(stateKey);
|
||||
const session = new RemoteSubagentSession(
|
||||
this.definition,
|
||||
this.context,
|
||||
this.messageBus,
|
||||
priorState,
|
||||
);
|
||||
|
||||
// Wire external abort signal to session abort
|
||||
const abortListener = () => void session.abort();
|
||||
_signal?.addEventListener('abort', abortListener, { once: true });
|
||||
|
||||
// Subscribe for parent session observability
|
||||
let unsubscribeParent: (() => void) | undefined;
|
||||
if (this._onAgentEvent) {
|
||||
unsubscribeParent = session.subscribe(this._onAgentEvent);
|
||||
}
|
||||
|
||||
// Subscribe to message events for live SubagentProgress updates
|
||||
const unsubscribeProgress = session.subscribe((event: AgentEvent) => {
|
||||
if (event.type === 'message' && updateOutput) {
|
||||
const currentProgress = session.getLatestProgress();
|
||||
if (currentProgress) updateOutput(currentProgress);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
if (updateOutput) {
|
||||
updateOutput({
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: SubagentState.RUNNING,
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'pending',
|
||||
type: 'thought',
|
||||
content: 'Working...',
|
||||
status: SubagentState.RUNNING,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await session.send({
|
||||
message: { content: [{ type: 'text', text: this.params.query }] },
|
||||
});
|
||||
|
||||
const result = await session.getResult();
|
||||
|
||||
// The protocol resolves aborts with an empty result rather than
|
||||
// rejecting. Detect this and surface proper error state.
|
||||
if (_signal?.aborted) {
|
||||
const partialProgress = session.getLatestProgress();
|
||||
const recentActivity = this.stopRunningActivities(
|
||||
partialProgress?.recentActivity ?? emptyActivity,
|
||||
SubagentState.CANCELLED,
|
||||
);
|
||||
const errorProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: SubagentState.CANCELLED,
|
||||
result:
|
||||
typeof partialProgress?.result === 'string'
|
||||
? partialProgress.result
|
||||
: '',
|
||||
recentActivity,
|
||||
};
|
||||
if (updateOutput) updateOutput(errorProgress);
|
||||
return {
|
||||
llmContent: [{ text: 'Operation cancelled by user' }],
|
||||
returnDisplay: errorProgress,
|
||||
};
|
||||
}
|
||||
|
||||
// Emit final completed progress
|
||||
if (updateOutput) {
|
||||
const finalProgress = session.getLatestProgress();
|
||||
if (finalProgress) updateOutput(finalProgress);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const partialProgress = session.getLatestProgress();
|
||||
const partialOutput =
|
||||
typeof partialProgress?.result === 'string'
|
||||
? partialProgress.result
|
||||
: '';
|
||||
const errorMessage = this.formatExecutionError(error);
|
||||
const fullDisplay = partialOutput
|
||||
? `${partialOutput}\n\n${errorMessage}`
|
||||
: errorMessage;
|
||||
|
||||
const isAbort =
|
||||
(error instanceof Error && error.name === 'AbortError') ||
|
||||
errorMessage.includes('Aborted');
|
||||
|
||||
const status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
|
||||
const recentActivity = this.stopRunningActivities(
|
||||
partialProgress?.recentActivity ?? emptyActivity,
|
||||
status,
|
||||
);
|
||||
|
||||
const errorProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: status,
|
||||
result: fullDisplay,
|
||||
recentActivity,
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
updateOutput(errorProgress);
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: [{ text: fullDisplay }],
|
||||
returnDisplay: errorProgress,
|
||||
};
|
||||
} finally {
|
||||
// Persist A2A state for next invocation — even on abort/error
|
||||
RemoteSessionInvocation.sessionState.set(
|
||||
stateKey,
|
||||
session.getSessionState(),
|
||||
);
|
||||
_signal?.removeEventListener('abort', abortListener);
|
||||
unsubscribeProgress();
|
||||
unsubscribeParent?.();
|
||||
}
|
||||
}
|
||||
|
||||
private stopRunningActivities(
|
||||
activity: SubagentActivityItem[],
|
||||
status: SubagentState,
|
||||
): SubagentActivityItem[] {
|
||||
const result: SubagentActivityItem[] = [];
|
||||
for (const item of activity) {
|
||||
result.push(
|
||||
item.status === SubagentState.RUNNING ? { ...item, status } : item,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an execution error into a user-friendly message.
|
||||
* Recognizes typed A2AAgentError subclasses and falls back to
|
||||
* a generic message for unknown errors.
|
||||
*/
|
||||
private formatExecutionError(error: unknown): string {
|
||||
if (error instanceof A2AAgentError) {
|
||||
return error.userMessage;
|
||||
}
|
||||
|
||||
return `Error calling remote agent: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,21 @@ class RemoteSubagentProtocol implements AgentProtocol {
|
||||
private readonly context: AgentLoopContext,
|
||||
// Required for API parity across protocol constructors (local, remote, legacy)
|
||||
_messageBus: MessageBus,
|
||||
initialState?: { contextId?: string; taskId?: string },
|
||||
) {
|
||||
this._agentName = definition.displayName ?? definition.name;
|
||||
if (initialState) {
|
||||
this.contextId = initialState.contextId;
|
||||
this.taskId = initialState.taskId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current A2A conversation state.
|
||||
* Used by the invocation layer to persist state across invocations.
|
||||
*/
|
||||
getSessionState(): { contextId?: string; taskId?: string } {
|
||||
return { contextId: this.contextId, taskId: this.taskId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -394,11 +407,13 @@ export class RemoteSubagentSession extends AgentSession {
|
||||
definition: RemoteAgentDefinition,
|
||||
context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
initialState?: { contextId?: string; taskId?: string },
|
||||
) {
|
||||
const protocol = new RemoteSubagentProtocol(
|
||||
definition,
|
||||
context,
|
||||
messageBus,
|
||||
initialState,
|
||||
);
|
||||
super(protocol);
|
||||
this._remoteProtocol = protocol;
|
||||
@@ -420,6 +435,14 @@ export class RemoteSubagentSession extends AgentSession {
|
||||
return this._remoteProtocol.getLatestProgress();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current A2A conversation state (contextId/taskId).
|
||||
* Used by the invocation layer to persist state across invocations.
|
||||
*/
|
||||
getSessionState(): { contextId?: string; taskId?: string } {
|
||||
return this._remoteProtocol.getSessionState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: start execution with a query string.
|
||||
* Equivalent to send({message: {content: [{type:'text', text: query}]}}).
|
||||
|
||||
@@ -220,18 +220,7 @@ describe('policyHelpers', () => {
|
||||
];
|
||||
|
||||
testCases.forEach(
|
||||
({
|
||||
name,
|
||||
model,
|
||||
useGemini31,
|
||||
hasAccess,
|
||||
authType,
|
||||
wrapsAround,
|
||||
...rest
|
||||
}) => {
|
||||
const releaseChannel = (rest as Record<string, unknown>)[
|
||||
'releaseChannel'
|
||||
] as string | undefined;
|
||||
({ name, model, useGemini31, hasAccess, authType, wrapsAround }) => {
|
||||
it(`achieves parity for: ${name}`, () => {
|
||||
const createBaseConfig = (dynamic: boolean) =>
|
||||
createMockConfig({
|
||||
@@ -241,7 +230,7 @@ describe('policyHelpers', () => {
|
||||
getGemini31FlashLiteLaunchedSync: () => false,
|
||||
getHasAccessToPreviewModel: () => hasAccess ?? true,
|
||||
getContentGeneratorConfig: () => ({ authType }),
|
||||
getReleaseChannel: () => releaseChannel ?? 'preview',
|
||||
getReleaseChannel: () => 'preview',
|
||||
modelConfigService: new ModelConfigService(DEFAULT_MODEL_CONFIGS),
|
||||
});
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ export function resolvePolicyChain(
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
releaseChannel: config.getReleaseChannel?.(),
|
||||
};
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -863,6 +863,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 +1912,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 +2740,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 +3512,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', () => {
|
||||
|
||||
@@ -177,6 +177,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 +834,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 +1569,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 +1832,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 +1929,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;
|
||||
}
|
||||
@@ -3299,6 +3331,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 (
|
||||
|
||||
@@ -467,7 +467,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
auto: {
|
||||
default: 'gemini-3-pro-preview',
|
||||
contexts: [
|
||||
{ condition: { releaseChannel: 'stable' }, target: 'gemini-2.5-pro' },
|
||||
{ condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' },
|
||||
{
|
||||
condition: { useGemini3_1: true, useCustomTools: true },
|
||||
@@ -559,10 +558,6 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
condition: { hasAccessToPreview: false },
|
||||
target: 'gemini-2.5-pro',
|
||||
},
|
||||
{
|
||||
condition: { releaseChannel: 'stable', requestedModels: ['auto'] },
|
||||
target: 'gemini-2.5-pro',
|
||||
},
|
||||
{
|
||||
condition: { requestedModels: ['gemini-2.5-pro', 'auto-gemini-2.5'] },
|
||||
target: 'gemini-2.5-pro',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
isProModel,
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
getAutoModelDescription,
|
||||
} from './models.js';
|
||||
import type { Config } from './config.js';
|
||||
import { ModelConfigService } from '../services/modelConfigService.js';
|
||||
@@ -704,3 +705,23 @@ describe('Gemini 3.1 Config Resolution', () => {
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoModelDescription', () => {
|
||||
it('should return Gemini 2.5 description when hasAccessToPreview is false', () => {
|
||||
const desc = getAutoModelDescription(false, false);
|
||||
expect(desc).toContain('gemini-2.5-pro');
|
||||
expect(desc).toContain('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,6 @@ export interface IModelConfigService {
|
||||
export interface ModelCapabilityContext {
|
||||
readonly modelConfigService: IModelConfigService;
|
||||
getExperimentalDynamicModelConfiguration(): boolean;
|
||||
getReleaseChannel?(): string;
|
||||
}
|
||||
|
||||
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
|
||||
@@ -97,16 +96,15 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||
export const DEFAULT_THINKING_MODE = 8192;
|
||||
|
||||
export function getAutoModelDescription(
|
||||
releaseChannel: string = 'stable',
|
||||
hasAccessToPreview: boolean,
|
||||
useGemini3_1: boolean = false,
|
||||
) {
|
||||
const isPreview = releaseChannel === 'preview';
|
||||
const proModel = isPreview
|
||||
const proModel = hasAccessToPreview
|
||||
? useGemini3_1
|
||||
? 'gemini-3.1-pro'
|
||||
: 'gemini-3-pro'
|
||||
: 'gemini-2.5-pro';
|
||||
const flashModel = isPreview ? 'gemini-3-flash' : 'gemini-2.5-flash';
|
||||
const flashModel = hasAccessToPreview ? 'gemini-3-flash' : 'gemini-2.5-flash';
|
||||
return `Let Gemini CLI decide the best model for the task: ${proModel}, ${flashModel}`;
|
||||
}
|
||||
|
||||
@@ -126,7 +124,6 @@ export function resolveModel(
|
||||
useCustomToolModel: boolean = false,
|
||||
hasAccessToPreview: boolean = true,
|
||||
config?: ModelCapabilityContext,
|
||||
releaseChannel?: string,
|
||||
): string {
|
||||
// Defensive check against non-string inputs at runtime
|
||||
const normalizedModel = Array.isArray(requestedModel)
|
||||
@@ -135,15 +132,12 @@ export function resolveModel(
|
||||
? String(requestedModel ?? '').trim() || ''
|
||||
: requestedModel.trim() || '';
|
||||
|
||||
const currentReleaseChannel = releaseChannel ?? config?.getReleaseChannel?.();
|
||||
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const resolved = config.modelConfigService.resolveModelId(normalizedModel, {
|
||||
useGemini3_1,
|
||||
useGemini3_1FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
releaseChannel: currentReleaseChannel,
|
||||
});
|
||||
|
||||
if (!hasAccessToPreview && isPreviewModel(resolved, config)) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -21,9 +21,9 @@ export class MessageBus extends EventEmitter {
|
||||
constructor(
|
||||
private readonly policyEngine: PolicyEngine,
|
||||
private readonly debug = false,
|
||||
private readonly isTrusted = true,
|
||||
) {
|
||||
super();
|
||||
this.debug = debug;
|
||||
}
|
||||
|
||||
private isValidMessage(message: Message): boolean {
|
||||
@@ -47,18 +47,32 @@ export class MessageBus extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Derives a child message bus scoped to a specific subagent.
|
||||
* Derived buses are untrusted.
|
||||
*/
|
||||
derive(subagentName: string): MessageBus {
|
||||
const bus = new MessageBus(this.policyEngine, this.debug);
|
||||
const bus = new MessageBus(this.policyEngine, this.debug, false);
|
||||
|
||||
bus.publish = async (message: Message) => {
|
||||
if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) {
|
||||
// Sanitization for untrusted callers:
|
||||
// 1. Remove forcedDecision to prevent policy bypass.
|
||||
// 2. Remove metadata (serverName, toolAnnotations, details) to prevent spoofing.
|
||||
// 3. Enforce subagent identity by prepending/setting the scope.
|
||||
const {
|
||||
forcedDecision: _forcedDecision,
|
||||
subagent: _subagent,
|
||||
serverName: _serverName,
|
||||
toolAnnotations: _toolAnnotations,
|
||||
details: _details,
|
||||
...otherFields
|
||||
} = message;
|
||||
|
||||
return this.publish({
|
||||
...message,
|
||||
...otherFields,
|
||||
subagent: message.subagent
|
||||
? `${subagentName}/${message.subagent}`
|
||||
: subagentName,
|
||||
});
|
||||
} as Message);
|
||||
}
|
||||
return this.publish(message);
|
||||
};
|
||||
@@ -95,7 +109,10 @@ export class MessageBus extends EventEmitter {
|
||||
message.subagent,
|
||||
);
|
||||
|
||||
const decision = message.forcedDecision ?? policyDecision;
|
||||
// Only trust forcedDecision if it comes from a trusted bus
|
||||
const decision =
|
||||
(this.isTrusted ? message.forcedDecision : undefined) ??
|
||||
policyDecision;
|
||||
|
||||
switch (decision) {
|
||||
case PolicyDecision.ALLOW:
|
||||
|
||||
@@ -177,8 +177,8 @@ export const stressTestProfile: ContextProfile = {
|
||||
name: 'Stress Test',
|
||||
config: {
|
||||
budget: {
|
||||
retainedTokens: 4000,
|
||||
maxTokens: 10000,
|
||||
retainedTokens: 1500,
|
||||
maxTokens: 5000,
|
||||
},
|
||||
processorOptions: {
|
||||
ToolMasking: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createSyntheticHistory,
|
||||
createMockContextConfig,
|
||||
setupContextComponentTest,
|
||||
deriveStableId,
|
||||
} from './testing/contextTestUtils.js';
|
||||
|
||||
describe('ContextManager Sync Pressure Barrier Tests', () => {
|
||||
@@ -32,10 +33,14 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
|
||||
);
|
||||
|
||||
// 2. Add System Prompt (Episode 0 - Protected)
|
||||
const envId = deriveStableId(['environment-context']);
|
||||
chatHistory.set([
|
||||
{
|
||||
id: 'h1',
|
||||
content: { role: 'user', parts: [{ text: 'System prompt' }] },
|
||||
id: envId,
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: '<session_context>\nSystem prompt' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'h2',
|
||||
@@ -74,8 +79,10 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
|
||||
|
||||
expect(projection.length).toBeLessThan(rawHistoryLength);
|
||||
|
||||
// Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation
|
||||
// Verify Episode 0 (System) was PRESERVED because it is pinned Turn 0.
|
||||
expect(projection[0].id).toBe(envId);
|
||||
expect(projection[0].content.role).toBe('user');
|
||||
|
||||
const projectionString = JSON.stringify(projection);
|
||||
expect(projectionString).toContain('User turn 17');
|
||||
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
|
||||
@@ -86,19 +93,13 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
|
||||
);
|
||||
|
||||
// Verify the latest turn is perfectly preserved at the back
|
||||
// Note: The HistoryHardener appends a "Please continue." user turn if we end on model,
|
||||
// so we look at the turns before the sentinel.
|
||||
const lastSentinel = contentNodes[contentNodes.length - 1].content;
|
||||
const lastModel = contentNodes[contentNodes.length - 2].content;
|
||||
const lastUser = contentNodes[contentNodes.length - 3].content;
|
||||
|
||||
expect(lastSentinel.role).toBe('user');
|
||||
expect(lastSentinel.parts![0].text).toBe('Please continue.');
|
||||
|
||||
expect(lastUser.role).toBe('user');
|
||||
expect(lastUser.parts![0].text).toBe('Final question.');
|
||||
const lastModel = contentNodes[contentNodes.length - 1].content;
|
||||
const lastUser = contentNodes[contentNodes.length - 2].content;
|
||||
|
||||
expect(lastModel.role).toBe('model');
|
||||
expect(lastModel.parts![0].text).toBe('Final answer.');
|
||||
|
||||
expect(lastUser.role).toBe('user');
|
||||
expect(lastUser.parts![0].text).toBe('Final question.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||
import { ContextManager } from './contextManager.js';
|
||||
import type { ContextProfile } from './config/profiles.js';
|
||||
import type { ContextEnvironment } from './pipeline/environment.js';
|
||||
import type { ContextTracer } from './tracer.js';
|
||||
import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
|
||||
import type {
|
||||
AgentChatHistory,
|
||||
HistoryTurn,
|
||||
} from '../core/agentChatHistory.js';
|
||||
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
|
||||
import { createMockEnvironment } from './testing/contextTestUtils.js';
|
||||
|
||||
describe('ContextManager', () => {
|
||||
let mockSidecar: ContextProfile;
|
||||
let mockEnv: ContextEnvironment;
|
||||
let mockTracer: ContextTracer;
|
||||
let mockOrchestrator: PipelineOrchestrator;
|
||||
let mockChatHistory: AgentChatHistory;
|
||||
let mockAdvancedTokenCalculator: AdvancedTokenCalculator;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockSidecar = {
|
||||
name: 'test-profile',
|
||||
config: { budget: { retainedTokens: 1000, maxTokens: 2000 } },
|
||||
buildPipelines: vi.fn().mockReturnValue([]),
|
||||
buildAsyncPipelines: vi.fn().mockReturnValue([]),
|
||||
} as unknown as ContextProfile;
|
||||
|
||||
mockEnv = createMockEnvironment();
|
||||
mockTracer = mockEnv.tracer;
|
||||
|
||||
mockOrchestrator = {
|
||||
setNodeProvider: vi.fn(),
|
||||
waitForPipelines: vi.fn().mockResolvedValue(undefined),
|
||||
executeTriggerSync: vi
|
||||
.fn()
|
||||
.mockImplementation(async (trigger, nodes) => nodes),
|
||||
shutdown: vi.fn(),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
|
||||
mockChatHistory = {
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
last: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
getTurnById: vi.fn(),
|
||||
getTurnsByIds: vi.fn(),
|
||||
getNeighboringTurns: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
get: vi.fn().mockReturnValue([]),
|
||||
setHistory: vi.fn(),
|
||||
getHistoryTurns: vi.fn().mockReturnValue([]),
|
||||
getRawHistory: vi.fn().mockReturnValue([]),
|
||||
addTurn: vi.fn(),
|
||||
updateTurn: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
} as unknown as AgentChatHistory;
|
||||
|
||||
mockAdvancedTokenCalculator = {
|
||||
getRawBaseUnits: vi.fn().mockReturnValue(0),
|
||||
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
|
||||
calculateTokensAndBaseUnits: vi
|
||||
.fn()
|
||||
.mockReturnValue({ tokens: 0, baseUnits: 0 }),
|
||||
} as unknown as AdvancedTokenCalculator;
|
||||
});
|
||||
|
||||
it('renderHistory should process pendingRequest via the new_message pipeline', async () => {
|
||||
const contextManager = new ContextManager(
|
||||
mockSidecar,
|
||||
mockEnv,
|
||||
mockTracer,
|
||||
mockOrchestrator,
|
||||
mockChatHistory,
|
||||
mockAdvancedTokenCalculator,
|
||||
);
|
||||
|
||||
const largeToolOutput = 'a'.repeat(10000);
|
||||
const pendingRequest: HistoryTurn = {
|
||||
id: 'pending-turn-1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'run_shell_command',
|
||||
response: {
|
||||
output: largeToolOutput,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await contextManager.renderHistory(pendingRequest);
|
||||
|
||||
expect(mockOrchestrator.executeTriggerSync).toHaveBeenCalledExactlyOnceWith(
|
||||
'new_message',
|
||||
expect.any(Array),
|
||||
expect.any(Set),
|
||||
);
|
||||
|
||||
// Check that the node passed to the orchestrator corresponds to our pendingRequest
|
||||
const call = (mockOrchestrator.executeTriggerSync as unknown as Mock).mock
|
||||
.calls[0];
|
||||
const passedNodes = call[1];
|
||||
const passedNodeIds = call[2];
|
||||
|
||||
expect(passedNodes).toHaveLength(1);
|
||||
expect(passedNodes[0].type).toBe('TOOL_EXECUTION');
|
||||
expect(passedNodes[0].payload.functionResponse.response.output).toBe(
|
||||
largeToolOutput,
|
||||
);
|
||||
expect(passedNodeIds.has(passedNodes[0].id)).toBe(true);
|
||||
});
|
||||
|
||||
it('renderHistory should exclude pendingRequest from the result (late binding)', async () => {
|
||||
const contextManager = new ContextManager(
|
||||
mockSidecar,
|
||||
mockEnv,
|
||||
mockTracer,
|
||||
mockOrchestrator,
|
||||
mockChatHistory,
|
||||
mockAdvancedTokenCalculator,
|
||||
);
|
||||
|
||||
const pendingRequest: HistoryTurn = {
|
||||
id: 'pending-turn-1',
|
||||
content: { role: 'user', parts: [{ text: 'Active prompt' }] },
|
||||
};
|
||||
|
||||
const { history, apiHistory } =
|
||||
await contextManager.renderHistory(pendingRequest);
|
||||
|
||||
// Should be empty because mockChatHistory has no historical turns
|
||||
expect(history).toHaveLength(0);
|
||||
expect(apiHistory).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -15,24 +15,24 @@ import type { ContextTracer } from './tracer.js';
|
||||
import type { ContextEnvironment } from './pipeline/environment.js';
|
||||
import type { ContextProfile } from './config/profiles.js';
|
||||
import type { PipelineOrchestrator } from './pipeline/orchestrator.js';
|
||||
import { HistoryObserver } from './historyObserver.js';
|
||||
import { render } from './graph/render.js';
|
||||
import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { deriveStableId } from '../utils/cryptoUtils.js';
|
||||
import { hardenHistory } from '../utils/historyHardening.js';
|
||||
import { checkContextInvariants } from './utils/invariantChecker.js';
|
||||
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
|
||||
|
||||
export class ContextManager {
|
||||
// The master state containing the pristine graph and current active graph.
|
||||
// Master state containing the pristine graph and current active graph.
|
||||
private buffer: ContextWorkingBufferImpl =
|
||||
ContextWorkingBufferImpl.initialize([]);
|
||||
|
||||
private readonly eventBus: ContextEventBus;
|
||||
|
||||
// Internal sub-components
|
||||
private readonly orchestrator: PipelineOrchestrator;
|
||||
private readonly historyObserver: HistoryObserver;
|
||||
|
||||
// Track what IDs have been evaluated for triggers to prevent redundant processing
|
||||
private readonly evaluatedNodeIds = new Set<string>();
|
||||
|
||||
// Hysteresis tracking to prevent utility call churn
|
||||
private lastTriggeredDeficit = 0;
|
||||
@@ -43,6 +43,7 @@ export class ContextManager {
|
||||
result: {
|
||||
history: HistoryTurn[];
|
||||
apiHistory: Content[];
|
||||
pendingApiHistory: Content[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
processedNodes: readonly ConcreteNode[];
|
||||
@@ -56,7 +57,7 @@ export class ContextManager {
|
||||
private readonly env: ContextEnvironment,
|
||||
private readonly tracer: ContextTracer,
|
||||
orchestrator: PipelineOrchestrator,
|
||||
chatHistory: AgentChatHistory,
|
||||
private readonly chatHistory: AgentChatHistory,
|
||||
private readonly advancedTokenCalculator: AdvancedTokenCalculator,
|
||||
private readonly headerProvider?: () => Promise<Content | undefined>,
|
||||
) {
|
||||
@@ -66,23 +67,8 @@ export class ContextManager {
|
||||
// Provide the orchestrator with a way to fetch the latest nodes from the live buffer
|
||||
this.orchestrator.setNodeProvider(() => this.buffer.nodes);
|
||||
|
||||
this.historyObserver = new HistoryObserver(
|
||||
chatHistory,
|
||||
this.env.eventBus,
|
||||
this.tracer,
|
||||
this.env.graphMapper,
|
||||
);
|
||||
|
||||
this.eventBus.onPristineHistoryUpdated((event) => {
|
||||
// Sync the entire pristine history chronologically
|
||||
this.buffer = this.buffer.syncPristineHistory(event.nodes);
|
||||
|
||||
this.evaluateTriggers(event.newNodes);
|
||||
});
|
||||
this.eventBus.onProcessorResult((event) => {
|
||||
// Defensive: Verify all targets are still present in the buffer.
|
||||
// If a synchronous render or a previous async task already removed them,
|
||||
// this result is stale and should be dropped.
|
||||
const currentIds = new Set(this.buffer.nodes.map((n) => n.id));
|
||||
const allTargetsPresent = event.targets.every((t) =>
|
||||
currentIds.has(t.id),
|
||||
@@ -100,14 +86,7 @@ export class ContextManager {
|
||||
event.targets,
|
||||
event.returnedNodes,
|
||||
);
|
||||
// We explicitly DO NOT call evaluateTriggers here.
|
||||
// The Context Manager is a one-way assembly line. It only evaluates triggers
|
||||
// when fundamentally new organic context is added via PristineHistoryUpdated.
|
||||
// Re-evaluating after a processor finishes creates infinite feedback loops if
|
||||
// the processor fails to reduce the token count below the threshold.
|
||||
});
|
||||
|
||||
this.historyObserver.start();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,21 +101,21 @@ export class ContextManager {
|
||||
*/
|
||||
shutdown() {
|
||||
this.orchestrator.shutdown();
|
||||
this.historyObserver.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates if the current working buffer exceeds configured budget thresholds,
|
||||
* firing consolidation events if necessary.
|
||||
*/
|
||||
private evaluateTriggers(newNodes: Set<string>) {
|
||||
private async evaluateTriggers(newNodes: Set<string>) {
|
||||
if (!this.sidecar.config.budget) return;
|
||||
|
||||
if (newNodes.size > 0) {
|
||||
this.eventBus.emitChunkReceived({
|
||||
nodes: this.buffer.nodes,
|
||||
targetNodeIds: newNodes,
|
||||
});
|
||||
await this.orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
this.buffer.nodes,
|
||||
newNodes,
|
||||
);
|
||||
}
|
||||
|
||||
const currentTokens = this.env.tokenCalculator.calculateConcreteListTokens(
|
||||
@@ -149,11 +128,6 @@ export class ContextManager {
|
||||
|
||||
// Identify nodes that must NEVER be truncated
|
||||
const protectedIds = this.getProtectedNodeIds(this.buffer.nodes);
|
||||
if (protectedIds.size > 0) {
|
||||
debugLogger.log(
|
||||
`[ContextManager] Pinning ${protectedIds.size} nodes (recent_turn or external_active_task) to prevent truncation.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Walk backwards finding nodes that fall out of the retained budget
|
||||
for (let i = this.buffer.nodes.length - 1; i >= 0; i--) {
|
||||
@@ -163,11 +137,7 @@ export class ContextManager {
|
||||
node,
|
||||
]);
|
||||
|
||||
// Loose Boundary Policy: If this node is the one that pushes us over the retained limit,
|
||||
// we KEEP it to prevent aggressive undershooting. We only age out nodes that are
|
||||
// strictly *older* than the boundary node.
|
||||
if (priorTokens > this.sidecar.config.budget.retainedTokens) {
|
||||
// Only age out if not protected
|
||||
if (!protectedIds.has(node.id)) {
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
@@ -178,17 +148,12 @@ export class ContextManager {
|
||||
const targetDeficit =
|
||||
currentTokens - this.sidecar.config.budget.retainedTokens;
|
||||
|
||||
// If the deficit has shrunk (e.g. after a consolidation), update the baseline
|
||||
// so we can track growth from this new, smaller deficit.
|
||||
if (targetDeficit < this.lastTriggeredDeficit) {
|
||||
this.lastTriggeredDeficit = targetDeficit;
|
||||
}
|
||||
|
||||
// Respect coalescing threshold for background work
|
||||
const threshold =
|
||||
this.sidecar.config.budget.coalescingThresholdTokens || 0;
|
||||
|
||||
// Only trigger if deficit has grown significantly since last time
|
||||
const growthSinceLast = targetDeficit - this.lastTriggeredDeficit;
|
||||
|
||||
if (
|
||||
@@ -199,25 +164,21 @@ export class ContextManager {
|
||||
this.env.tokenCalculator.garbageCollectCache(
|
||||
new Set(this.buffer.nodes.map((n) => n.id)),
|
||||
);
|
||||
this.eventBus.emitConsolidationNeeded({
|
||||
nodes: this.buffer.nodes,
|
||||
targetDeficit,
|
||||
targetNodeIds: agedOutNodes,
|
||||
});
|
||||
|
||||
// Trigger synchronous consolidation for budget deficit
|
||||
await this.orchestrator.executeTriggerSync(
|
||||
'nodes_aged_out',
|
||||
this.buffer.nodes,
|
||||
agedOutNodes,
|
||||
new Set(protectedIds.keys()),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Budget is healthy, reset hysteresis
|
||||
this.lastTriggeredDeficit = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies 'pinned' nodes that should not be truncated.
|
||||
* This includes:
|
||||
* 1. The entire last turn (Recent context).
|
||||
* 2. Active tool calls (calls without responses in the graph).
|
||||
*/
|
||||
private getProtectedNodeIds(
|
||||
nodes: readonly ConcreteNode[],
|
||||
extraProtectedIds: Set<string> = new Set(),
|
||||
@@ -225,17 +186,18 @@ export class ContextManager {
|
||||
const protectionMap = new Map<string, string>();
|
||||
if (nodes.length === 0) return protectionMap;
|
||||
|
||||
// 1. Identify all nodes belonging to the last turn (Recent context)
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
const lastTurnId = lastNode.turnId;
|
||||
const envTurnId = `turn_${deriveStableId(['environment-context'])}`;
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.turnId === lastTurnId) {
|
||||
protectionMap.set(node.id, 'recent_turn');
|
||||
} else if (node.turnId === envTurnId) {
|
||||
protectionMap.set(node.id, 'environment_context');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Any externally requested protections
|
||||
for (const id of extraProtectedIds) {
|
||||
protectionMap.set(id, 'external_active_task');
|
||||
}
|
||||
@@ -243,11 +205,6 @@ export class ContextManager {
|
||||
return protectionMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the raw, uncompressed Episodic Context Graph graph.
|
||||
* Useful for internal tool rendering (like the trace viewer).
|
||||
* Note: This is an expensive, deep clone operation.
|
||||
*/
|
||||
getPristineGraph(): readonly ConcreteNode[] {
|
||||
const pristineSet = new Map<string, ConcreteNode>();
|
||||
for (const node of this.buffer.nodes) {
|
||||
@@ -256,58 +213,70 @@ export class ContextManager {
|
||||
pristineSet.set(root.id, root);
|
||||
}
|
||||
}
|
||||
// We sort them by timestamp to ensure they are returned in chronological order
|
||||
return Array.from(pristineSet.values()).sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a virtual view of the pristine graph, substituting in variants
|
||||
* up to the configured token budget.
|
||||
* This is the view that will eventually be projected back to the LLM.
|
||||
*/
|
||||
getNodes(): readonly ConcreteNode[] {
|
||||
return [...this.buffer.nodes];
|
||||
}
|
||||
|
||||
getEnvironment(): ContextEnvironment {
|
||||
return this.env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the final 'gc_backstop' pipeline if necessary, enforcing the token budget,
|
||||
* and maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
|
||||
* This is the primary method called by the agent framework before sending a request.
|
||||
* Generates a virtual view of the pristine graph, substituting in variants
|
||||
* up to the configured token budget.
|
||||
*/
|
||||
async renderHistory(
|
||||
pendingRequest?: HistoryTurn,
|
||||
pendingRequest?: { id: string; content: Content },
|
||||
activeTaskIds: Set<string> = new Set(),
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{
|
||||
history: HistoryTurn[];
|
||||
apiHistory: Content[];
|
||||
pendingApiHistory: Content[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
processedNodes: readonly ConcreteNode[];
|
||||
}> {
|
||||
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
|
||||
|
||||
let previewNodes: ConcreteNode[] = [];
|
||||
if (pendingRequest) {
|
||||
previewNodes = this.env.graphMapper.applyEvent({
|
||||
type: 'PUSH',
|
||||
payload: [pendingRequest],
|
||||
});
|
||||
// 1. Explicit Sync with the durable history.
|
||||
// This replaces the background HistoryObserver.
|
||||
const currentHistory = this.chatHistory.get();
|
||||
const pristineNodes = this.env.graphMapper.sync(currentHistory);
|
||||
|
||||
this.buffer = this.buffer.syncPristineHistory(pristineNodes);
|
||||
|
||||
// Identify truly "new" nodes that haven't been evaluated for triggers yet.
|
||||
const newPrimalNodes = new Set<string>();
|
||||
for (const node of pristineNodes) {
|
||||
if (!this.evaluatedNodeIds.has(node.id)) {
|
||||
newPrimalNodes.add(node.id);
|
||||
this.evaluatedNodeIds.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Preview the pending request.
|
||||
let previewNodes: readonly ConcreteNode[] = [];
|
||||
if (pendingRequest) {
|
||||
previewNodes = this.env.graphMapper.sync([pendingRequest]);
|
||||
|
||||
const previewNodeIds = new Set(previewNodes.map((n) => n.id));
|
||||
|
||||
previewNodes = await this.orchestrator.executeTriggerSync(
|
||||
'new_message',
|
||||
previewNodes,
|
||||
previewNodeIds,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Trigger evaluation (Sync budget management).
|
||||
await this.evaluateTriggers(newPrimalNodes);
|
||||
|
||||
// --- Hot Start Calibration ---
|
||||
// If we are resuming a session with history, we don't want the adaptive token calculator
|
||||
// to fly blind on its first GC pass. We do a one-time API calibration.
|
||||
const hotStartPromise = (async () => {
|
||||
if (!this.hasPerformedHotStart) {
|
||||
this.hasPerformedHotStart = true;
|
||||
|
||||
if (this.buffer.nodes.length > 0) {
|
||||
const nodesForHotStart = [...this.buffer.nodes, ...previewNodes];
|
||||
await this.performHotStartCalibration(nodesForHotStart, abortSignal);
|
||||
@@ -315,14 +284,11 @@ export class ContextManager {
|
||||
}
|
||||
})();
|
||||
|
||||
// 1. Synchronous Pressure Barrier: Wait for background management pipelines to finish.
|
||||
// We run hot start calibration in parallel to hide the network latency.
|
||||
await Promise.all([this.orchestrator.waitForPipelines(), hotStartPromise]);
|
||||
|
||||
let nodes = this.buffer.nodes;
|
||||
const previewNodeIds = new Set<string>();
|
||||
|
||||
// Apply the preview nodes to the final graph
|
||||
if (previewNodes.length > 0) {
|
||||
for (const n of previewNodes) {
|
||||
previewNodeIds.add(n.id);
|
||||
@@ -330,13 +296,10 @@ export class ContextManager {
|
||||
nodes = [...nodes, ...previewNodes];
|
||||
}
|
||||
|
||||
// 2. Fetch Header and calculate tokens
|
||||
const header = this.headerProvider
|
||||
? await this.headerProvider()
|
||||
: undefined;
|
||||
|
||||
// 3. Cache Check (Anomaly 3): If nodes haven't changed, return previous result.
|
||||
// We combine the graph hash with a hash of the header to ensure total freshness.
|
||||
const graphHash = nodes.map((n) => n.id).join('|');
|
||||
const headerHash = header ? JSON.stringify(header.parts) : 'no-header';
|
||||
const totalHash = `${graphHash}::${headerHash}`;
|
||||
@@ -350,7 +313,6 @@ export class ContextManager {
|
||||
|
||||
const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds);
|
||||
|
||||
// Apply final GC Backstop pressure barrier synchronously before mapping
|
||||
const renderResult = await render(
|
||||
nodes,
|
||||
this.orchestrator,
|
||||
@@ -358,22 +320,22 @@ export class ContextManager {
|
||||
this.tracer,
|
||||
this.env,
|
||||
this.advancedTokenCalculator,
|
||||
protectionReasons,
|
||||
header,
|
||||
previewNodeIds,
|
||||
{
|
||||
protectionReasons,
|
||||
header,
|
||||
lateBindPrompt: !!pendingRequest,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
history: renderedHistory,
|
||||
pendingHistory,
|
||||
didApplyManagement,
|
||||
baseUnits,
|
||||
processedNodes,
|
||||
} = renderResult;
|
||||
|
||||
if (didApplyManagement) {
|
||||
// Commit the GC backstop results back to the master buffer.
|
||||
// We filter out preview nodes because they are ephemeral and will be
|
||||
// added to history naturally by the client after the turn completes.
|
||||
this.buffer = this.buffer.applyProcessorResult(
|
||||
'sync_backstop',
|
||||
this.buffer.nodes,
|
||||
@@ -381,54 +343,52 @@ export class ContextManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Structural validation in debug mode
|
||||
checkContextInvariants(this.buffer.nodes, 'RenderHistory');
|
||||
|
||||
this.tracer.logEvent('ContextManager', 'Finished rendering');
|
||||
|
||||
// We must temporarily append the pendingRequest (if any) before hardening.
|
||||
// Otherwise, the hardener will see dangling functionCalls and inject sentinels
|
||||
// even though the pendingRequest provides the required functionResponses.
|
||||
const fullHistoryToHarden = pendingRequest
|
||||
? [...renderedHistory, pendingRequest]
|
||||
: renderedHistory;
|
||||
|
||||
const hardenedHistory = hardenHistory(fullHistoryToHarden, {
|
||||
const allHistory = [...renderedHistory, ...pendingHistory];
|
||||
const hardenedAllHistory = hardenHistory(allHistory, {
|
||||
sentinels: this.sidecar.sentinels,
|
||||
});
|
||||
|
||||
if (pendingRequest) {
|
||||
const last = hardenedHistory[hardenedHistory.length - 1];
|
||||
if (last && last.content.parts) {
|
||||
const numPartsToRemove = pendingRequest.content.parts?.length || 0;
|
||||
if (
|
||||
numPartsToRemove > 0 &&
|
||||
last.content.parts.length > numPartsToRemove
|
||||
) {
|
||||
last.content.parts.splice(-numPartsToRemove);
|
||||
} else {
|
||||
hardenedHistory.pop();
|
||||
}
|
||||
} else {
|
||||
hardenedHistory.pop();
|
||||
const firstPendingId = pendingHistory[0]?.id;
|
||||
let splitIndex = renderedHistory.length;
|
||||
if (firstPendingId) {
|
||||
const foundIndex = hardenedAllHistory.findIndex(
|
||||
(h) => h.id === firstPendingId,
|
||||
);
|
||||
if (foundIndex !== -1) {
|
||||
splitIndex = foundIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const apiHistory = hardenedHistory.map((h) => h.content);
|
||||
const apiHistory = hardenedAllHistory
|
||||
.slice(0, splitIndex)
|
||||
.map((h) => h.content);
|
||||
|
||||
const pendingApiHistory = hardenedAllHistory
|
||||
.slice(splitIndex)
|
||||
.map((h) => h.content);
|
||||
|
||||
if (header) {
|
||||
apiHistory.unshift(header);
|
||||
}
|
||||
|
||||
const result = {
|
||||
history: hardenedHistory,
|
||||
history: renderedHistory,
|
||||
apiHistory,
|
||||
pendingApiHistory,
|
||||
didApplyManagement,
|
||||
baseUnits,
|
||||
processedNodes,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
this.lastRenderCache = { nodesHash: totalHash, result };
|
||||
this.lastRenderCache = {
|
||||
nodesHash: totalHash,
|
||||
result,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -436,47 +396,28 @@ export class ContextManager {
|
||||
nodes: readonly ConcreteNode[],
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
const history = this.env.graphMapper.fromGraph(nodes);
|
||||
const contents = history.map((h) => h.content);
|
||||
|
||||
try {
|
||||
this.tracer.logEvent(
|
||||
'ContextManager',
|
||||
'Performing Hot Start Token Calibration',
|
||||
);
|
||||
const { totalTokens } = await this.env.llmClient.countTokens({
|
||||
modelConfigKey: { model: 'context-calibrator' },
|
||||
contents,
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
const contents = this.env.graphMapper.fromGraph(nodes);
|
||||
const rawContents = contents.map((h) => h.content);
|
||||
const header = this.headerProvider
|
||||
? await this.headerProvider()
|
||||
: undefined;
|
||||
const combinedHistory = header ? [header, ...rawContents] : rawContents;
|
||||
|
||||
const baseUnits =
|
||||
this.advancedTokenCalculator.getRawBaseUnits(nodes) +
|
||||
(header
|
||||
? this.advancedTokenCalculator.getRawBaseUnitsForContent(header)
|
||||
: 0);
|
||||
|
||||
// We only make the network call if we have actual contents to send,
|
||||
// avoiding 400 Bad Request errors from the API.
|
||||
if (combinedHistory.length > 0) {
|
||||
const result = await this.env.llmClient.countTokens({
|
||||
contents: combinedHistory,
|
||||
abortSignal,
|
||||
if (totalTokens !== undefined) {
|
||||
this.env.eventBus.emitTokenGroundTruth({
|
||||
actualTokens: totalTokens,
|
||||
promptBaseUnits: this.advancedTokenCalculator.getRawBaseUnits(nodes),
|
||||
});
|
||||
if (result.totalTokens > 0) {
|
||||
this.env.eventBus.emitTokenGroundTruth({
|
||||
actualTokens: result.totalTokens,
|
||||
promptBaseUnits: baseUnits,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Hot start calibration is purely an optimization. If the network fails or auth is weird,
|
||||
// we silently swallow and fallback to the un-calibrated 1.0 ratio heuristic.
|
||||
this.tracer.logEvent(
|
||||
'ContextManager',
|
||||
'Hot Start Token Calibration Failed (Ignored)',
|
||||
{ error },
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.warn('[ContextManager] Hot start calibration failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
getEnvironment(): ContextEnvironment {
|
||||
return this.env;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import { hardenHistory } from '../../utils/historyHardening.js';
|
||||
describe('ContextGraphMapper (Round-Trip Fidelity)', () => {
|
||||
it('should flawlessly round-trip a complex history containing parallel tool calls and responses', () => {
|
||||
// 1. Define a complex, worst-case scenario history
|
||||
const envId = 'd04923d38bb0f6017037e74183378ef4';
|
||||
const originalHistory: HistoryTurn[] = [
|
||||
{
|
||||
id: 'system_prompt_id',
|
||||
id: envId,
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: '<session_context>\nSystem Prompt here' }],
|
||||
@@ -90,11 +91,8 @@ describe('ContextGraphMapper (Round-Trip Fidelity)', () => {
|
||||
|
||||
// 3. Translate History -> Graph
|
||||
const mapper = new ContextGraphMapper();
|
||||
// Simulate the HistoryObserver capturing the push
|
||||
const nodes = mapper.applyEvent({
|
||||
type: 'SYNC_FULL',
|
||||
payload: originalHistory,
|
||||
});
|
||||
// Simulate the sync
|
||||
const nodes = mapper.sync(originalHistory);
|
||||
|
||||
// 4. Translate Graph -> History
|
||||
const reconstructedHistory = mapper.fromGraph(nodes);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import { ContextGraphBuilder } from './toGraph.js';
|
||||
import type { HistoryEvent, HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
import { fromGraph } from './fromGraph.js';
|
||||
import { NodeIdService } from './nodeIdService.js';
|
||||
|
||||
@@ -17,8 +17,8 @@ export class ContextGraphMapper {
|
||||
this.builder = new ContextGraphBuilder(this.idService);
|
||||
}
|
||||
|
||||
applyEvent(event: HistoryEvent): ConcreteNode[] {
|
||||
return this.builder.processHistory(event.payload);
|
||||
sync(turns: readonly HistoryTurn[]): ConcreteNode[] {
|
||||
return this.builder.processHistory(turns);
|
||||
}
|
||||
|
||||
fromGraph(nodes: readonly ConcreteNode[]): HistoryTurn[] {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
describe('render', () => {
|
||||
it('should filter out previewNodeIds', async () => {
|
||||
it('should render all provided nodes', async () => {
|
||||
const mockNodes: ConcreteNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
@@ -34,7 +34,6 @@ describe('render', () => {
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
];
|
||||
const previewNodeIds = new Set(['preview-1']);
|
||||
|
||||
const orchestrator = {} as PipelineOrchestrator;
|
||||
const sidecar = { config: {} } as ContextProfile; // No budget
|
||||
@@ -44,6 +43,7 @@ describe('render', () => {
|
||||
baseUnits: 100,
|
||||
}),
|
||||
getRawBaseUnits: vi.fn().mockReturnValue(100),
|
||||
calculateConcreteListTokens: vi.fn().mockReturnValue(100),
|
||||
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
|
||||
@@ -69,12 +69,17 @@ describe('render', () => {
|
||||
tracer,
|
||||
env,
|
||||
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
|
||||
new Map(),
|
||||
undefined,
|
||||
previewNodeIds,
|
||||
{
|
||||
protectionReasons: new Map(),
|
||||
header: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.history).toEqual([{ text: '1' }, { text: '2' }]);
|
||||
expect(result.history).toEqual([
|
||||
{ text: '1' },
|
||||
{ text: '2' },
|
||||
{ text: 'preview-1' },
|
||||
]);
|
||||
expect(result.baseUnits).toBe(100);
|
||||
});
|
||||
|
||||
@@ -134,6 +139,10 @@ describe('render', () => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
};
|
||||
|
||||
const env = {
|
||||
@@ -165,9 +174,10 @@ describe('render', () => {
|
||||
tracer,
|
||||
env,
|
||||
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
|
||||
new Map(),
|
||||
undefined,
|
||||
new Set(),
|
||||
{
|
||||
protectionReasons: new Map(),
|
||||
header: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -228,6 +238,10 @@ describe('render', () => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
};
|
||||
|
||||
const env = {
|
||||
@@ -259,9 +273,10 @@ describe('render', () => {
|
||||
tracer,
|
||||
env,
|
||||
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
|
||||
new Map(),
|
||||
undefined,
|
||||
new Set(),
|
||||
{
|
||||
protectionReasons: new Map(),
|
||||
header: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -270,4 +285,66 @@ describe('render', () => {
|
||||
expect(surviving).toEqual(['B', 'C']); // A is dropped
|
||||
expect(result.baseUnits).toBe(160000);
|
||||
});
|
||||
|
||||
it('should exclude the last turn when lateBindPrompt is true', async () => {
|
||||
const mockNodes: ConcreteNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: NodeType.USER_PROMPT,
|
||||
turnId: 'turn-1',
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
{
|
||||
id: '2',
|
||||
type: NodeType.AGENT_THOUGHT,
|
||||
turnId: 'turn-2',
|
||||
payload: {} as Part,
|
||||
} as unknown as ConcreteNode,
|
||||
];
|
||||
|
||||
const orchestrator = {
|
||||
executeTriggerSync: vi.fn(async (trigger, nodes) => nodes),
|
||||
} as unknown as PipelineOrchestrator;
|
||||
const sidecar = { config: {} } as ContextProfile; // No budget
|
||||
const mockAdvancedTokenCalculator = {
|
||||
calculateTokensAndBaseUnits: vi.fn().mockReturnValue({
|
||||
tokens: 100,
|
||||
baseUnits: 100,
|
||||
}),
|
||||
getRawBaseUnits: vi.fn().mockReturnValue(50),
|
||||
calculateConcreteListTokens: vi.fn().mockReturnValue(100),
|
||||
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
|
||||
const env = {
|
||||
tokenCalculator: {
|
||||
calculateConcreteListTokens: vi.fn().mockReturnValue(100),
|
||||
calculateTokenBreakdown: vi.fn().mockReturnValue({}),
|
||||
},
|
||||
graphMapper: {
|
||||
fromGraph: vi.fn((nodes: readonly ConcreteNode[]) =>
|
||||
nodes.map((n) => ({ text: n.id })),
|
||||
),
|
||||
},
|
||||
} as unknown as ContextEnvironment;
|
||||
const tracer = {
|
||||
logEvent: vi.fn(),
|
||||
} as unknown as ContextTracer;
|
||||
|
||||
const result = await render(
|
||||
mockNodes,
|
||||
orchestrator,
|
||||
sidecar,
|
||||
tracer,
|
||||
env,
|
||||
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
|
||||
{
|
||||
lateBindPrompt: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.history).toEqual([{ text: '1' }]); // Turn 2 (node 2) is excluded
|
||||
expect(result.pendingHistory).toEqual([{ text: '2' }]); // Turn 2 is included here
|
||||
expect(result.baseUnits).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type { ContextTracer } from '../tracer.js';
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
|
||||
@@ -14,6 +15,17 @@ import { performCalibration } from '../utils/tokenCalibration.js';
|
||||
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
|
||||
export interface RenderOptions {
|
||||
protectionReasons?: Map<string, string>;
|
||||
header?: Content;
|
||||
/**
|
||||
* If true, the most recent turn in the graph will not be considered for
|
||||
* consolidation (snapshots) or included in the returned history.
|
||||
* This is used for "late-binding" the prompt.
|
||||
*/
|
||||
lateBindPrompt?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the Episodic Context Graph back into a list of HistoryTurns for transmission.
|
||||
* It applies synchronous context management (GC backstop) if the budget is exceeded.
|
||||
@@ -25,15 +37,15 @@ export async function render(
|
||||
tracer: ContextTracer,
|
||||
env: ContextEnvironment,
|
||||
advancedTokenCalculator: AdvancedTokenCalculator,
|
||||
protectionReasons: Map<string, string> = new Map(),
|
||||
header?: Content,
|
||||
previewNodeIds: ReadonlySet<string> = new Set(),
|
||||
options: RenderOptions = {},
|
||||
): Promise<{
|
||||
history: HistoryTurn[];
|
||||
pendingHistory: HistoryTurn[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
processedNodes: readonly ConcreteNode[];
|
||||
}> {
|
||||
const { protectionReasons = new Map(), header, lateBindPrompt } = options;
|
||||
let headerTokens = 0;
|
||||
let headerBaseUnits = 0;
|
||||
if (header) {
|
||||
@@ -43,19 +55,36 @@ export async function render(
|
||||
headerBaseUnits = costs.baseUnits;
|
||||
}
|
||||
|
||||
const lastTurnId = nodes[nodes.length - 1]?.turnId;
|
||||
|
||||
if (!sidecar.config.budget) {
|
||||
const visibleNodes = nodes.filter((n) => !previewNodeIds.has(n.id));
|
||||
const contents = env.graphMapper.fromGraph(visibleNodes);
|
||||
const allVisibleNodes = nodes;
|
||||
|
||||
const managedNodes =
|
||||
lateBindPrompt && lastTurnId
|
||||
? allVisibleNodes.filter((n) => n.turnId !== lastTurnId)
|
||||
: allVisibleNodes;
|
||||
|
||||
const pendingNodes =
|
||||
lateBindPrompt && lastTurnId
|
||||
? allVisibleNodes.filter((n) => n.turnId === lastTurnId)
|
||||
: [];
|
||||
|
||||
const history = env.graphMapper.fromGraph(managedNodes);
|
||||
const pendingHistory = env.graphMapper.fromGraph(pendingNodes);
|
||||
|
||||
tracer.logEvent('Render', 'Render Context to LLM (No Budget)', {
|
||||
renderedContext: contents,
|
||||
renderedContext: history,
|
||||
pendingContext: pendingHistory,
|
||||
});
|
||||
|
||||
// In all cases, retrieve raw base units from the token calculator interface
|
||||
const baseUnits =
|
||||
advancedTokenCalculator.getRawBaseUnits(nodes) + headerBaseUnits;
|
||||
advancedTokenCalculator.getRawBaseUnits(allVisibleNodes) +
|
||||
headerBaseUnits;
|
||||
|
||||
return {
|
||||
history: contents,
|
||||
history,
|
||||
pendingHistory,
|
||||
didApplyManagement: false,
|
||||
baseUnits,
|
||||
processedNodes: nodes,
|
||||
@@ -64,7 +93,7 @@ export async function render(
|
||||
|
||||
const maxTokens = sidecar.config.budget.maxTokens;
|
||||
|
||||
const { tokens: graphTokens, baseUnits: graphBaseUnits } =
|
||||
const { tokens: graphTokens } =
|
||||
advancedTokenCalculator.calculateTokensAndBaseUnits(nodes);
|
||||
|
||||
const currentTokens = graphTokens + headerTokens;
|
||||
@@ -94,20 +123,39 @@ export async function render(
|
||||
'Render',
|
||||
`View is within maxTokens (${currentTokens} <= ${maxTokens}). Returning view.`,
|
||||
);
|
||||
const visibleNodes = nodes.filter((n) => !previewNodeIds.has(n.id));
|
||||
const contents = env.graphMapper.fromGraph(visibleNodes);
|
||||
|
||||
const allVisibleNodes = nodes;
|
||||
|
||||
const managedNodes =
|
||||
lateBindPrompt && lastTurnId
|
||||
? allVisibleNodes.filter((n) => n.turnId !== lastTurnId)
|
||||
: allVisibleNodes;
|
||||
|
||||
const pendingNodes =
|
||||
lateBindPrompt && lastTurnId
|
||||
? allVisibleNodes.filter((n) => n.turnId === lastTurnId)
|
||||
: [];
|
||||
|
||||
const history = env.graphMapper.fromGraph(managedNodes);
|
||||
const pendingHistory = env.graphMapper.fromGraph(pendingNodes);
|
||||
|
||||
tracer.logEvent('Render', 'Render Context for LLM', {
|
||||
renderedContext: contents,
|
||||
renderedContext: history,
|
||||
pendingContext: pendingHistory,
|
||||
});
|
||||
performCalibration(
|
||||
env,
|
||||
visibleNodes,
|
||||
contents.map((h) => h.content),
|
||||
);
|
||||
|
||||
performCalibration(env, allVisibleNodes, [
|
||||
...history.map((h) => h.content),
|
||||
...pendingHistory.map((h) => h.content),
|
||||
]);
|
||||
|
||||
return {
|
||||
history: contents,
|
||||
history,
|
||||
pendingHistory,
|
||||
didApplyManagement: false,
|
||||
baseUnits: graphBaseUnits + headerBaseUnits,
|
||||
baseUnits:
|
||||
advancedTokenCalculator.getRawBaseUnits(allVisibleNodes) +
|
||||
headerBaseUnits,
|
||||
processedNodes: nodes,
|
||||
};
|
||||
}
|
||||
@@ -117,23 +165,31 @@ export async function render(
|
||||
`View exceeds maxTokens (${currentTokens} > ${maxTokens}). Hitting Synchronous Pressure Barrier.`,
|
||||
{ targetDelta },
|
||||
);
|
||||
debugLogger.log(
|
||||
`Context Manager Synchronous Barrier triggered: View at ${currentTokens} tokens (limit: ${maxTokens}).`,
|
||||
);
|
||||
|
||||
// Calculate exactly which nodes aged out of the retainedTokens budget to form our target delta
|
||||
const agedOutNodes = new Set<string>();
|
||||
let rollingTokens = 0;
|
||||
// Start from newest and count backwards
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const node = nodes[i];
|
||||
const priorTokens = rollingTokens;
|
||||
const nodeTokens = env.tokenCalculator.calculateConcreteListTokens([node]);
|
||||
rollingTokens += nodeTokens;
|
||||
|
||||
// Loose Boundary Policy: Keep the node that crosses the boundary
|
||||
if (priorTokens > sidecar.config.budget.retainedTokens) {
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (lateBindPrompt && lastTurnId) {
|
||||
for (const node of nodes) {
|
||||
if (node.turnId === lastTurnId) {
|
||||
agedOutNodes.delete(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const processedNodes = await orchestrator.executeTriggerSync(
|
||||
'gc_backstop',
|
||||
nodes,
|
||||
@@ -141,7 +197,6 @@ export async function render(
|
||||
protectedIds,
|
||||
);
|
||||
|
||||
// Apply skipList logic to abstract over summarized nodes
|
||||
const skipList = new Set<string>();
|
||||
for (const node of processedNodes) {
|
||||
if (node.abstractsIds) {
|
||||
@@ -149,24 +204,43 @@ export async function render(
|
||||
}
|
||||
}
|
||||
|
||||
const visibleNodes = processedNodes.filter(
|
||||
(n) => !skipList.has(n.id) && !previewNodeIds.has(n.id),
|
||||
const allVisibleNodes = processedNodes.filter((n) => !skipList.has(n.id));
|
||||
|
||||
const managedNodes =
|
||||
lateBindPrompt && lastTurnId
|
||||
? allVisibleNodes.filter((n) => n.turnId !== lastTurnId)
|
||||
: allVisibleNodes;
|
||||
|
||||
const pendingNodes =
|
||||
lateBindPrompt && lastTurnId
|
||||
? allVisibleNodes.filter((n) => n.turnId === lastTurnId)
|
||||
: [];
|
||||
|
||||
const history = env.graphMapper.fromGraph(managedNodes);
|
||||
const pendingHistory = env.graphMapper.fromGraph(pendingNodes);
|
||||
|
||||
const finalTokens =
|
||||
advancedTokenCalculator.calculateConcreteListTokens(allVisibleNodes);
|
||||
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
|
||||
renderedContextSanitized: history,
|
||||
pendingContextSanitized: pendingHistory,
|
||||
});
|
||||
debugLogger.log(
|
||||
`Context Manager finished. Final actual token count: ${finalTokens}.`,
|
||||
);
|
||||
|
||||
const contents = env.graphMapper.fromGraph(visibleNodes);
|
||||
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
|
||||
renderedContextSanitized: contents,
|
||||
});
|
||||
performCalibration(
|
||||
env,
|
||||
visibleNodes,
|
||||
contents.map((h) => h.content),
|
||||
);
|
||||
performCalibration(env, allVisibleNodes, [
|
||||
...history.map((h) => h.content),
|
||||
...pendingHistory.map((h) => h.content),
|
||||
]);
|
||||
|
||||
return {
|
||||
history: contents,
|
||||
history,
|
||||
pendingHistory,
|
||||
didApplyManagement: true,
|
||||
baseUnits:
|
||||
advancedTokenCalculator.getRawBaseUnits(visibleNodes) + headerBaseUnits,
|
||||
advancedTokenCalculator.getRawBaseUnits(allVisibleNodes) +
|
||||
headerBaseUnits,
|
||||
processedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type { NodeIdService } from './nodeIdService.js';
|
||||
import type { HistoryTurn } from '../../core/agentChatHistory.js';
|
||||
import { isSnapshotState } from '../utils/snapshotGenerator.js';
|
||||
import { deriveStableId } from '../../utils/cryptoUtils.js';
|
||||
import { ensureStableToolIds } from '../../utils/sessionUtils.js';
|
||||
|
||||
// Global WeakMap to cache hashes for Part objects.
|
||||
// This optimizes getStableId by avoiding redundant stringify/hash operations
|
||||
@@ -41,9 +43,9 @@ function isFileDataPart(
|
||||
);
|
||||
}
|
||||
|
||||
function isFunctionCallPart(
|
||||
part: Part,
|
||||
): part is Part & { functionCall: { id: string; name: string } } {
|
||||
function isFunctionCallPart(part: Part): part is Part & {
|
||||
functionCall: { id?: string; name: string; args: Record<string, unknown> };
|
||||
} {
|
||||
return (
|
||||
typeof part.functionCall === 'object' &&
|
||||
part.functionCall !== null &&
|
||||
@@ -51,9 +53,13 @@ function isFunctionCallPart(
|
||||
);
|
||||
}
|
||||
|
||||
function isFunctionResponsePart(
|
||||
part: Part,
|
||||
): part is Part & { functionResponse: { id: string; name: string } } {
|
||||
function isFunctionResponsePart(part: Part): part is Part & {
|
||||
functionResponse: {
|
||||
id?: string;
|
||||
name: string;
|
||||
response: Record<string, unknown>;
|
||||
};
|
||||
} {
|
||||
return (
|
||||
typeof part.functionResponse === 'object' &&
|
||||
part.functionResponse !== null &&
|
||||
@@ -121,19 +127,27 @@ export function getStableId(
|
||||
.digest('hex');
|
||||
id = `file_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
} else if (isFunctionCallPart(part)) {
|
||||
contentHash = createHash('sha256')
|
||||
.update(
|
||||
`call:${part.functionCall.name}:${JSON.stringify(part.functionCall.args)}`,
|
||||
)
|
||||
.digest('hex');
|
||||
id = `call_h_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
if (part.functionCall.id) {
|
||||
id = `call_${part.functionCall.id}`;
|
||||
} else {
|
||||
contentHash = createHash('sha256')
|
||||
.update(
|
||||
`call:${part.functionCall.name}:${JSON.stringify(part.functionCall.args)}`,
|
||||
)
|
||||
.digest('hex');
|
||||
id = `call_h_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
}
|
||||
} else if (isFunctionResponsePart(part)) {
|
||||
contentHash = createHash('sha256')
|
||||
.update(
|
||||
`resp:${part.functionResponse.name}:${JSON.stringify(part.functionResponse.response)}`,
|
||||
)
|
||||
.digest('hex');
|
||||
id = `resp_h_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
if (part.functionResponse.id) {
|
||||
id = `resp_${part.functionResponse.id}`;
|
||||
} else {
|
||||
contentHash = createHash('sha256')
|
||||
.update(
|
||||
`resp:${part.functionResponse.name}:${JSON.stringify(part.functionResponse.response)}`,
|
||||
)
|
||||
.digest('hex');
|
||||
id = `resp_h_${contentHash}_${turnSalt}_${partIdx}`;
|
||||
}
|
||||
} else if (isExecutableCodePart(part)) {
|
||||
contentHash = createHash('sha256')
|
||||
.update(
|
||||
@@ -174,6 +188,8 @@ export class ContextGraphBuilder {
|
||||
constructor(private readonly idService: NodeIdService) {}
|
||||
|
||||
processHistory(history: readonly HistoryTurn[]): ConcreteNode[] {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ensureStableToolIds(history as HistoryTurn[]);
|
||||
const nodes: ConcreteNode[] = [];
|
||||
|
||||
for (let turnIdx = 0; turnIdx < history.length; turnIdx++) {
|
||||
@@ -181,42 +197,32 @@ export class ContextGraphBuilder {
|
||||
const msg = turn.content;
|
||||
if (!msg.parts) continue;
|
||||
|
||||
// Defensive: Skip legacy environment header regardless of where it appears.
|
||||
// We now manage this as an orthogonal late-addition header.
|
||||
if (msg.role === 'user' && msg.parts.length === 1) {
|
||||
const text = msg.parts[0].text;
|
||||
if (
|
||||
text?.startsWith('<session_context>') &&
|
||||
text?.includes('This is the Gemini CLI')
|
||||
) {
|
||||
debugLogger.log(
|
||||
'[ContextGraphBuilder] Skipping legacy environment header turn from graph.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const turnSalt = turn.id;
|
||||
const turnId = `turn_${turnSalt}`;
|
||||
const turnId = turnSalt.startsWith('turn_')
|
||||
? turnSalt
|
||||
: `turn_${turnSalt}`;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
|
||||
const part = msg.parts[partIdx];
|
||||
const apiId =
|
||||
isFunctionResponsePart(part) &&
|
||||
typeof part.functionResponse.id === 'string'
|
||||
? part.functionResponse.id
|
||||
: isFunctionCallPart(part) &&
|
||||
typeof part.functionCall.id === 'string'
|
||||
? part.functionCall.id
|
||||
: undefined;
|
||||
|
||||
// Skip legacy session context headers if they appear later in history (after Turn 0).
|
||||
// We identify Turn 0 by its deterministic ID.
|
||||
const envTurnId = deriveStableId(['environment-context']);
|
||||
if (
|
||||
isTextPart(part) &&
|
||||
part.text.trim().startsWith('<session_context>') &&
|
||||
turnSalt !== envTurnId
|
||||
) {
|
||||
debugLogger.log(
|
||||
'[ContextGraphBuilder] Skipping legacy environment header turn from graph.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSnapshot = isTextPart(part) && isSnapshotState(part.text);
|
||||
|
||||
// Use stable API ID if available, otherwise anchor to the turn and index.
|
||||
const id = apiId
|
||||
? `${apiId}_${turnSalt}_${partIdx}`
|
||||
: `${turnSalt}_${partIdx}`;
|
||||
const id = getStableId(part, this.idService, turnSalt, partIdx);
|
||||
|
||||
const node: ConcreteNode = {
|
||||
id,
|
||||
@@ -231,19 +237,12 @@ export class ContextGraphBuilder {
|
||||
turnId,
|
||||
};
|
||||
nodes.push(node);
|
||||
this.idService.set(part, id);
|
||||
}
|
||||
} else if (msg.role === 'model') {
|
||||
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
|
||||
const part = msg.parts[partIdx];
|
||||
const apiId =
|
||||
isFunctionCallPart(part) && typeof part.functionCall.id === 'string'
|
||||
? part.functionCall.id
|
||||
: undefined;
|
||||
|
||||
const id = apiId
|
||||
? `${apiId}_${turnSalt}_${partIdx}`
|
||||
: `${turnSalt}_${partIdx}`;
|
||||
const id = getStableId(part, this.idService, turnSalt, partIdx);
|
||||
|
||||
const node: ConcreteNode = {
|
||||
id,
|
||||
@@ -256,7 +255,6 @@ export class ContextGraphBuilder {
|
||||
turnId,
|
||||
};
|
||||
nodes.push(node);
|
||||
this.idService.set(part, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentChatHistory,
|
||||
HistoryEvent,
|
||||
} from '../core/agentChatHistory.js';
|
||||
import type { ContextGraphMapper } from './graph/mapper.js';
|
||||
import type { ContextEventBus } from './eventBus.js';
|
||||
import type { ContextTracer } from './tracer.js';
|
||||
|
||||
/**
|
||||
* Connects the raw AgentChatHistory to the ContextManager.
|
||||
* It maps raw messages into Episodic Intermediate Representation (Context Graph)
|
||||
* and evaluates background triggers whenever history changes.
|
||||
*/
|
||||
export class HistoryObserver {
|
||||
private unsubscribeHistory?: () => void;
|
||||
|
||||
private readonly seenNodeIds = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly chatHistory: AgentChatHistory,
|
||||
private readonly eventBus: ContextEventBus,
|
||||
private readonly tracer: ContextTracer,
|
||||
private readonly graphMapper: ContextGraphMapper,
|
||||
) {}
|
||||
|
||||
private processEvent = (event: HistoryEvent) => {
|
||||
if (event.type === 'CLEAR') {
|
||||
this.seenNodeIds.clear();
|
||||
}
|
||||
|
||||
if (event.type === 'SILENT_SYNC') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always process the FULL history to provide a complete view to the ContextManager.
|
||||
// The ContextManager relies on the 'nodes' array to be the TOTAL set of valid pristine nodes.
|
||||
const fullHistory = this.chatHistory.get();
|
||||
const nodes = this.graphMapper.applyEvent({
|
||||
...event,
|
||||
payload: fullHistory,
|
||||
});
|
||||
|
||||
const newNodes = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
if (!this.seenNodeIds.has(node.id)) {
|
||||
newNodes.add(node.id);
|
||||
this.seenNodeIds.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.tracer.logEvent(
|
||||
'HistoryObserver',
|
||||
`Rebuilt pristine graph from ${event.type} event`,
|
||||
{ nodesSize: nodes.length, newNodesCount: newNodes.size },
|
||||
);
|
||||
|
||||
this.eventBus.emitPristineHistoryUpdated({
|
||||
nodes,
|
||||
newNodes,
|
||||
});
|
||||
};
|
||||
|
||||
start() {
|
||||
if (this.unsubscribeHistory) {
|
||||
this.unsubscribeHistory();
|
||||
}
|
||||
|
||||
this.unsubscribeHistory = this.chatHistory.subscribe(this.processEvent);
|
||||
|
||||
// Process any existing history immediately upon start
|
||||
const existing = this.chatHistory.get();
|
||||
if (existing && existing.length > 0) {
|
||||
this.processEvent({ type: 'SYNC_FULL', payload: existing });
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.unsubscribeHistory) {
|
||||
this.unsubscribeHistory();
|
||||
this.unsubscribeHistory = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ import { NodeDistillationProcessorOptionsSchema } from './processors/nodeDistill
|
||||
import { StateSnapshotProcessorOptionsSchema } from './processors/stateSnapshotProcessor.js';
|
||||
import { StateSnapshotAsyncProcessorOptionsSchema } from './processors/stateSnapshotAsyncProcessor.js';
|
||||
import { RollingSummaryProcessorOptionsSchema } from './processors/rollingSummaryProcessor.js';
|
||||
import { getEnvironmentContext } from '../utils/environmentContext.js';
|
||||
import { AdaptiveTokenCalculator } from './utils/adaptiveTokenCalculator.js';
|
||||
import { estimateContextBreakdown } from '../core/loggingContentGenerator.js';
|
||||
import { NodeBehaviorRegistry } from './graph/behaviorRegistry.js';
|
||||
@@ -136,7 +135,6 @@ export async function initializeContextManager(
|
||||
sidecarProfile.buildPipelines(env),
|
||||
sidecarProfile.buildAsyncPipelines(env),
|
||||
env,
|
||||
eventBus,
|
||||
tracer,
|
||||
);
|
||||
|
||||
@@ -147,9 +145,5 @@ export async function initializeContextManager(
|
||||
orchestrator,
|
||||
chat.agentHistory,
|
||||
calculator,
|
||||
async () => {
|
||||
const parts = await getEnvironmentContext(config);
|
||||
return { role: 'user', parts };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
ProcessArgs,
|
||||
} from '../pipeline.js';
|
||||
import type { PipelineDef, AsyncPipelineDef } from '../config/types.js';
|
||||
import type { ContextEventBus } from '../eventBus.js';
|
||||
import type { ConcreteNode, UserPrompt } from '../graph/types.js';
|
||||
|
||||
// A realistic mock processor that modifies the text of the first target node
|
||||
@@ -77,11 +76,10 @@ function createMockAsyncProcessor(
|
||||
|
||||
describe('PipelineOrchestrator (Component)', () => {
|
||||
let env: ContextEnvironment;
|
||||
let eventBus: ContextEventBus;
|
||||
let orchestrator: PipelineOrchestrator;
|
||||
|
||||
beforeEach(() => {
|
||||
env = createMockEnvironment();
|
||||
eventBus = env.eventBus;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -92,13 +90,13 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
pipelines: PipelineDef[],
|
||||
asyncPipelines: AsyncPipelineDef[] = [],
|
||||
) => {
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
orchestrator = new PipelineOrchestrator(
|
||||
pipelines,
|
||||
asyncPipelines,
|
||||
env,
|
||||
eventBus,
|
||||
env.tracer,
|
||||
);
|
||||
|
||||
return orchestrator;
|
||||
};
|
||||
|
||||
@@ -207,13 +205,14 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
const node1 = createDummyNode('ep1', NodeType.USER_PROMPT, 10);
|
||||
const node2 = createDummyNode('ep1', NodeType.AGENT_THOUGHT, 20);
|
||||
|
||||
eventBus.emitChunkReceived({
|
||||
nodes: [node1, node2],
|
||||
targetNodeIds: new Set([node2.id]),
|
||||
});
|
||||
await orchestrator.executeTriggerSync(
|
||||
'nodes_added',
|
||||
[node1, node2],
|
||||
new Set([node2.id]),
|
||||
);
|
||||
|
||||
// Yield event loop
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(executeSpy).toHaveBeenCalledTimes(1);
|
||||
const callArgs = executeSpy.mock.calls[0][0];
|
||||
|
||||
@@ -10,11 +10,7 @@ import type {
|
||||
PipelineDef,
|
||||
PipelineTrigger,
|
||||
} from '../config/types.js';
|
||||
import type {
|
||||
ContextEnvironment,
|
||||
ContextEventBus,
|
||||
ContextTracer,
|
||||
} from './environment.js';
|
||||
import type { ContextEnvironment, ContextTracer } from './environment.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { InboxSnapshotImpl } from './inbox.js';
|
||||
import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js';
|
||||
@@ -30,10 +26,9 @@ export class PipelineOrchestrator {
|
||||
private readonly pipelines: PipelineDef[],
|
||||
private readonly asyncPipelines: AsyncPipelineDef[],
|
||||
private readonly env: ContextEnvironment,
|
||||
private readonly eventBus: ContextEventBus,
|
||||
private readonly tracer: ContextTracer,
|
||||
) {
|
||||
this.setupTriggers();
|
||||
// Background timers not fully implemented in V1 yet
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,181 +65,6 @@ export class PipelineOrchestrator {
|
||||
);
|
||||
}
|
||||
|
||||
private setupTriggers() {
|
||||
const bindTriggers = <P extends PipelineDef | AsyncPipelineDef>(
|
||||
pipelines: P[],
|
||||
executeFn: (
|
||||
pipeline: P,
|
||||
nodes: readonly ConcreteNode[],
|
||||
targets: ReadonlySet<string>,
|
||||
protectedIds: ReadonlySet<string>,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
for (const pipeline of pipelines) {
|
||||
for (const trigger of pipeline.triggers) {
|
||||
if (typeof trigger === 'object' && trigger.type === 'timer') {
|
||||
const timer = setInterval(() => {
|
||||
// Background timers not fully implemented in V1 yet
|
||||
}, trigger.intervalMs);
|
||||
this.activeTimers.push(timer);
|
||||
} else if (
|
||||
trigger === 'retained_exceeded' ||
|
||||
trigger === 'nodes_aged_out'
|
||||
) {
|
||||
this.eventBus.onConsolidationNeeded((event) => {
|
||||
void executeFn(
|
||||
pipeline,
|
||||
event.nodes,
|
||||
event.targetNodeIds,
|
||||
new Set(),
|
||||
);
|
||||
});
|
||||
} else if (trigger === 'new_message' || trigger === 'nodes_added') {
|
||||
this.eventBus.onChunkReceived((event) => {
|
||||
void executeFn(
|
||||
pipeline,
|
||||
event.nodes,
|
||||
event.targetNodeIds,
|
||||
new Set(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncExecution = async (
|
||||
pipeline: PipelineDef,
|
||||
nodes: readonly ConcreteNode[],
|
||||
targets: ReadonlySet<string>,
|
||||
protectedIds: ReadonlySet<string>,
|
||||
) => {
|
||||
if (this.pipelineScheduled.has(pipeline.name)) {
|
||||
debugLogger.log(
|
||||
`[Orchestrator] Pipeline ${pipeline.name} already scheduled (sync), dropping.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.pipelineScheduled.add(pipeline.name);
|
||||
|
||||
const existing =
|
||||
this.pipelineMutex.get(pipeline.name) || Promise.resolve();
|
||||
|
||||
const nextPromise = (async () => {
|
||||
try {
|
||||
await existing;
|
||||
this.pipelineScheduled.delete(pipeline.name);
|
||||
|
||||
const latestNodes = this.nodeProvider ? this.nodeProvider() : nodes;
|
||||
const latestTargets = latestNodes.filter((n) => targets.has(n.id));
|
||||
|
||||
debugLogger.log(
|
||||
`[Orchestrator] Executing sync pipeline ${pipeline.name} with ${latestTargets.length} latest targets.`,
|
||||
);
|
||||
|
||||
if (latestTargets.length === 0) {
|
||||
debugLogger.log(
|
||||
`[Orchestrator] No latest targets for sync pipeline ${pipeline.name}, returning.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.executePipelineAsync(
|
||||
pipeline,
|
||||
latestNodes,
|
||||
new Set(targets),
|
||||
new Set(protectedIds),
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Sync pipeline chain ${pipeline.name} failed:`, e);
|
||||
}
|
||||
})();
|
||||
|
||||
this.pipelineMutex.set(pipeline.name, nextPromise);
|
||||
const pipelineId = `${pipeline.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.pendingPipelines.set(pipelineId, nextPromise);
|
||||
void nextPromise.finally(() => {
|
||||
this.pendingPipelines.delete(pipelineId);
|
||||
if (this.pipelineMutex.get(pipeline.name) === nextPromise) {
|
||||
this.pipelineMutex.delete(pipeline.name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAsyncExecution = async (
|
||||
pipeline: AsyncPipelineDef,
|
||||
nodes: readonly ConcreteNode[],
|
||||
targets: ReadonlySet<string>,
|
||||
) => {
|
||||
if (this.pipelineScheduled.has(pipeline.name)) {
|
||||
debugLogger.log(
|
||||
`[Orchestrator] Pipeline ${pipeline.name} already scheduled (async), dropping.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.pipelineScheduled.add(pipeline.name);
|
||||
|
||||
const existing =
|
||||
this.pipelineMutex.get(pipeline.name) || Promise.resolve();
|
||||
|
||||
const nextPromise = (async () => {
|
||||
try {
|
||||
await existing;
|
||||
this.pipelineScheduled.delete(pipeline.name);
|
||||
|
||||
const latestNodes = this.nodeProvider ? this.nodeProvider() : nodes;
|
||||
const latestTargets = latestNodes.filter((n) => targets.has(n.id));
|
||||
|
||||
debugLogger.log(
|
||||
`[Orchestrator] Executing async pipeline ${pipeline.name} with ${latestTargets.length} latest targets.`,
|
||||
);
|
||||
|
||||
const inboxSnapshot = new InboxSnapshotImpl(
|
||||
this.env.inbox.getMessages() || [],
|
||||
);
|
||||
|
||||
for (const processor of pipeline.processors) {
|
||||
debugLogger.log(
|
||||
`[Orchestrator] Running async processor ${processor.id}`,
|
||||
);
|
||||
await processor.process({
|
||||
targets: latestTargets,
|
||||
inbox: inboxSnapshot,
|
||||
buffer: ContextWorkingBufferImpl.initialize(latestNodes),
|
||||
});
|
||||
}
|
||||
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
|
||||
} catch (e) {
|
||||
debugLogger.error(`Async pipeline chain ${pipeline.name} failed:`, e);
|
||||
}
|
||||
})();
|
||||
|
||||
this.pipelineMutex.set(pipeline.name, nextPromise);
|
||||
const pipelineId = `${pipeline.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.pendingPipelines.set(pipelineId, nextPromise);
|
||||
void nextPromise.finally(() => {
|
||||
this.pendingPipelines.delete(pipelineId);
|
||||
if (this.pipelineMutex.get(pipeline.name) === nextPromise) {
|
||||
this.pipelineMutex.delete(pipeline.name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
bindTriggers(this.pipelines, (pipeline, nodes, targets, protectedIds) =>
|
||||
handleSyncExecution(pipeline, nodes, targets, protectedIds),
|
||||
);
|
||||
|
||||
bindTriggers(this.asyncPipelines, (pipeline, nodes, targets) =>
|
||||
handleAsyncExecution(pipeline, nodes, targets),
|
||||
);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const timer of this.activeTimers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async executeTriggerSync(
|
||||
trigger: PipelineTrigger,
|
||||
nodes: readonly ConcreteNode[],
|
||||
@@ -256,6 +76,8 @@ export class PipelineOrchestrator {
|
||||
totalNodes: nodes.length,
|
||||
targetNodes: triggerTargets.size,
|
||||
});
|
||||
|
||||
// First, run any sync pipelines matching this trigger
|
||||
let currentBuffer = ContextWorkingBufferImpl.initialize(nodes);
|
||||
const triggerPipelines = this.pipelines.filter((p) =>
|
||||
p.triggers.includes(trigger),
|
||||
@@ -320,93 +142,86 @@ export class PipelineOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
// After sync pipelines finish, trigger any matching async pipelines in the background
|
||||
void this.executeTriggerAsync(trigger, currentBuffer.nodes, triggerTargets);
|
||||
|
||||
// Success! Drain consumed messages
|
||||
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
|
||||
|
||||
return currentBuffer.nodes;
|
||||
}
|
||||
|
||||
private async executePipelineAsync(
|
||||
pipeline: PipelineDef,
|
||||
private async executeTriggerAsync(
|
||||
trigger: PipelineTrigger,
|
||||
nodes: readonly ConcreteNode[],
|
||||
triggerTargets: Set<string>,
|
||||
protectedTurnIds: ReadonlySet<string> = new Set(),
|
||||
triggerTargets: ReadonlySet<string>,
|
||||
) {
|
||||
this.tracer.logEvent(
|
||||
'Orchestrator',
|
||||
`Triggering async pipeline: ${pipeline.name}`,
|
||||
{
|
||||
triggerTargets: triggerTargets.size,
|
||||
totalNodes: nodes.length,
|
||||
},
|
||||
);
|
||||
if (!nodes || nodes.length === 0) return;
|
||||
|
||||
let currentBuffer = ContextWorkingBufferImpl.initialize(nodes);
|
||||
const inboxSnapshot = new InboxSnapshotImpl(
|
||||
this.env.inbox.getMessages() || [],
|
||||
const asyncPipelines = this.asyncPipelines.filter((p) =>
|
||||
p.triggers.includes(trigger),
|
||||
);
|
||||
|
||||
for (const processor of pipeline.processors) {
|
||||
try {
|
||||
this.tracer.logEvent(
|
||||
'Orchestrator',
|
||||
`Executing processor: ${processor.id} (async)`,
|
||||
{ nodeCountBefore: currentBuffer.nodes.length },
|
||||
);
|
||||
|
||||
const allowedTargets = currentBuffer.nodes.filter((n) =>
|
||||
this.isNodeAllowed(n, triggerTargets, protectedTurnIds),
|
||||
);
|
||||
|
||||
const returnedNodes = await processor.process({
|
||||
buffer: currentBuffer,
|
||||
targets: allowedTargets,
|
||||
inbox: inboxSnapshot,
|
||||
});
|
||||
|
||||
currentBuffer = currentBuffer.applyProcessorResult(
|
||||
processor.id,
|
||||
allowedTargets,
|
||||
returnedNodes,
|
||||
);
|
||||
|
||||
const addedNodes = returnedNodes.filter(
|
||||
(n) => !allowedTargets.some((at) => at.id === n.id),
|
||||
);
|
||||
const removedNodes = allowedTargets.filter(
|
||||
(at) => !returnedNodes.some((n) => n.id === at.id),
|
||||
);
|
||||
|
||||
this.tracer.logEvent('Orchestrator', 'Transformation Lineage (Async)', {
|
||||
processorId: processor.id,
|
||||
inputNodeCount: allowedTargets.length,
|
||||
outputNodeCount: returnedNodes.length,
|
||||
removedNodeIds: removedNodes.map((n) => n.id),
|
||||
addedNodes: addedNodes.map((n) => ({
|
||||
id: n.id,
|
||||
replacesId: n.replacesId,
|
||||
abstractsIds: n.abstractsIds,
|
||||
approxTokens: this.env.tokenCalculator.calculateConcreteListTokens([
|
||||
n,
|
||||
]),
|
||||
})),
|
||||
});
|
||||
|
||||
this.eventBus.emitProcessorResult({
|
||||
processorId: processor.id,
|
||||
targets: allowedTargets,
|
||||
returnedNodes,
|
||||
});
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Pipeline ${pipeline.name} failed async at ${processor.id}:`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const pipeline of asyncPipelines) {
|
||||
void this.handleAsyncExecution(pipeline, nodes, triggerTargets);
|
||||
}
|
||||
}
|
||||
|
||||
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
|
||||
private async handleAsyncExecution(
|
||||
pipeline: AsyncPipelineDef,
|
||||
nodes: readonly ConcreteNode[],
|
||||
targets: ReadonlySet<string>,
|
||||
) {
|
||||
if (this.pipelineScheduled.has(pipeline.name)) {
|
||||
return;
|
||||
}
|
||||
this.pipelineScheduled.add(pipeline.name);
|
||||
|
||||
const existing = this.pipelineMutex.get(pipeline.name) || Promise.resolve();
|
||||
|
||||
const nextPromise = (async () => {
|
||||
try {
|
||||
await existing;
|
||||
this.pipelineScheduled.delete(pipeline.name);
|
||||
|
||||
const latestNodes = this.nodeProvider ? this.nodeProvider() : nodes;
|
||||
const latestTargets = latestNodes.filter((n) => targets.has(n.id));
|
||||
|
||||
if (latestTargets.length === 0) return;
|
||||
|
||||
debugLogger.log(
|
||||
`[Orchestrator] Executing async pipeline ${pipeline.name}`,
|
||||
);
|
||||
|
||||
const inboxSnapshot = new InboxSnapshotImpl(
|
||||
this.env.inbox.getMessages() || [],
|
||||
);
|
||||
|
||||
for (const processor of pipeline.processors) {
|
||||
await processor.process({
|
||||
targets: latestTargets,
|
||||
inbox: inboxSnapshot,
|
||||
buffer: ContextWorkingBufferImpl.initialize(latestNodes),
|
||||
});
|
||||
}
|
||||
this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds());
|
||||
} catch (e) {
|
||||
debugLogger.error(`Async pipeline chain ${pipeline.name} failed:`, e);
|
||||
}
|
||||
})();
|
||||
|
||||
this.pipelineMutex.set(pipeline.name, nextPromise);
|
||||
const pipelineId = `${pipeline.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
this.pendingPipelines.set(pipelineId, nextPromise);
|
||||
void nextPromise.finally(() => {
|
||||
this.pendingPipelines.delete(pipelineId);
|
||||
if (this.pipelineMutex.get(pipeline.name) === nextPromise) {
|
||||
this.pipelineMutex.delete(pipeline.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
for (const timer of this.activeTimers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,15 @@ export function createStateSnapshotAsyncProcessor(
|
||||
maxStateTokens: options.maxStateTokens,
|
||||
},
|
||||
);
|
||||
|
||||
env.tracer.logEvent(
|
||||
'StateSnapshotAsyncProcessor',
|
||||
'Snapshot Synthesized',
|
||||
{
|
||||
snapshotText,
|
||||
},
|
||||
);
|
||||
|
||||
const newConsumedIds = [
|
||||
...previousConsumedIds,
|
||||
...targets.map((t) => t.id),
|
||||
|
||||
@@ -90,6 +90,13 @@ export function createStateSnapshotProcessor(
|
||||
const isValid = consumedIds.every((id) => targetIds.has(id));
|
||||
|
||||
if (isValid) {
|
||||
env.tracer.logEvent(
|
||||
'StateSnapshotProcessor',
|
||||
'Snapshot Spliced from Inbox',
|
||||
{
|
||||
snapshotText: newText,
|
||||
},
|
||||
);
|
||||
debugLogger.log(
|
||||
`[StateSnapshotProcessor] Successfully spliced PROPOSED_SNAPSHOT from Inbox into Graph. Consumed ${consumedIds.length} nodes.`,
|
||||
);
|
||||
@@ -186,6 +193,11 @@ export function createStateSnapshotProcessor(
|
||||
maxStateTokens: options.maxStateTokens,
|
||||
},
|
||||
);
|
||||
|
||||
env.tracer.logEvent('StateSnapshotProcessor', 'Snapshot Synthesized', {
|
||||
snapshotText,
|
||||
});
|
||||
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
if (baselineIdToConsume && !consumedIds.includes(baselineIdToConsume)) {
|
||||
consumedIds.push(baselineIdToConsume);
|
||||
|
||||
+40
-226
File diff suppressed because one or more lines are too long
@@ -17,16 +17,18 @@ expect.addSnapshotSerializer({
|
||||
(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test(
|
||||
val,
|
||||
) ||
|
||||
/^[0-9a-f]{32}$/i.test(val) ||
|
||||
/\b[0-9a-f]{32}\b/i.test(val) ||
|
||||
/\bsynth_[a-zA-Z0-9_]+_[0-9a-f]{32}\b/.test(val) ||
|
||||
/[\\/]tmp[\\/]sim/.test(val)),
|
||||
print: (val) => {
|
||||
if (typeof val !== 'string') return `"${val}"`;
|
||||
let scrubbed = val
|
||||
.replace(
|
||||
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
|
||||
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
|
||||
'<UUID>',
|
||||
)
|
||||
.replace(/\b[0-9a-f]{32}\b/gi, '<UUID>')
|
||||
.replace(/\bsynth_[a-zA-Z0-9_]+_[0-9a-f]{32}\b/g, 'synth_<NAME>_<HASH>')
|
||||
.replace(/[\\/]tmp[\\/]sim[^\s"'\]]*/g, '<MOCKED_DIR>');
|
||||
|
||||
// Also scrub timestamps in filenames like blob_1234567890_...
|
||||
|
||||
@@ -83,7 +83,6 @@ export class SimulationHarness {
|
||||
config.buildPipelines(this.env),
|
||||
config.buildAsyncPipelines(this.env),
|
||||
this.env,
|
||||
this.eventBus,
|
||||
this.tracer,
|
||||
);
|
||||
this.contextManager = new ContextManager(
|
||||
@@ -97,24 +96,38 @@ export class SimulationHarness {
|
||||
}
|
||||
|
||||
async simulateTurn(messages: Content[]) {
|
||||
// 1. Append the new messages
|
||||
// In the new turn-based flow, we simulate the 'next' prompt or turn
|
||||
// by calling renderHistory on the pending content.
|
||||
|
||||
// For the purpose of the simulation, we'll treat the first message as the 'pending' one
|
||||
// if it hasn't been added to history yet.
|
||||
const pendingContent = messages[messages.length - 1];
|
||||
|
||||
// 1. Render to trigger sync and management
|
||||
const { processedNodes } = await this.contextManager.renderHistory({
|
||||
id: randomUUID(),
|
||||
content: pendingContent,
|
||||
});
|
||||
|
||||
const tokensBefore =
|
||||
this.env.tokenCalculator.calculateConcreteListTokens(processedNodes);
|
||||
|
||||
// 2. Append the new messages to durable history
|
||||
const currentHistory = this.chatHistory.get();
|
||||
const turns = messages.map((m) => ({ id: randomUUID(), content: m }));
|
||||
this.chatHistory.set([...currentHistory, ...turns]);
|
||||
|
||||
// 2. Measure tokens immediately after append
|
||||
const tokensBefore = this.env.tokenCalculator.calculateConcreteListTokens(
|
||||
this.contextManager.getNodes(),
|
||||
);
|
||||
|
||||
// 3. Yield to event loop and wait for async pipelines to finish
|
||||
// 3. Wait for any async pipelines triggered by the sync
|
||||
await this.contextManager.waitForPipelines();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100)); // Extra beat for event bus propagation
|
||||
|
||||
// 4. Measure tokens after background processors
|
||||
const tokensAfter = this.env.tokenCalculator.calculateConcreteListTokens(
|
||||
this.contextManager.getNodes(),
|
||||
);
|
||||
// 4. Measure tokens after background processors (requires another render or sync check)
|
||||
// In the new model, we'd need to re-render to see the effect of async processors
|
||||
// that might have finished.
|
||||
const { processedNodes: nodesAfter } =
|
||||
await this.contextManager.renderHistory();
|
||||
|
||||
const tokensAfter =
|
||||
this.env.tokenCalculator.calculateConcreteListTokens(nodesAfter);
|
||||
|
||||
this.tokenTrajectory.push({
|
||||
turnIndex: this.currentTurnIndex++,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { vi } from 'vitest';
|
||||
import { AgentChatHistory } from '../../core/agentChatHistory.js';
|
||||
import { ContextManager } from '../contextManager.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
export { deriveStableId } from '../../utils/cryptoUtils.js';
|
||||
import { ContextTracer } from '../tracer.js';
|
||||
import { ContextEnvironmentImpl } from '../pipeline/environmentImpl.js';
|
||||
import { ContextEventBus } from '../eventBus.js';
|
||||
@@ -317,7 +318,6 @@ export function setupContextComponentTest(
|
||||
sidecar.buildPipelines(env),
|
||||
sidecar.buildAsyncPipelines(env),
|
||||
env,
|
||||
eventBus,
|
||||
tracer,
|
||||
);
|
||||
|
||||
|
||||
@@ -16,61 +16,34 @@ export interface HistoryTurn {
|
||||
readonly content: Content;
|
||||
}
|
||||
|
||||
export type HistoryEventType = 'PUSH' | 'SYNC_FULL' | 'CLEAR' | 'SILENT_SYNC';
|
||||
|
||||
export interface HistoryEvent {
|
||||
type: HistoryEventType;
|
||||
payload: readonly HistoryTurn[];
|
||||
}
|
||||
|
||||
export type HistoryListener = (event: HistoryEvent) => void;
|
||||
|
||||
/**
|
||||
* The 'Strong Owner' of chat history turns.
|
||||
* It ensures that every turn in the session is associated with a durable ID.
|
||||
*/
|
||||
export class AgentChatHistory {
|
||||
private history: HistoryTurn[] = [];
|
||||
private listeners: Set<HistoryListener> = new Set();
|
||||
|
||||
constructor(initialTurns: HistoryTurn[] = []) {
|
||||
this.history = [...initialTurns];
|
||||
}
|
||||
|
||||
subscribe(listener: HistoryListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
// Emit initial state to new subscriber
|
||||
listener({ type: 'SYNC_FULL', payload: this.history });
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notify(type: HistoryEventType, payload: readonly HistoryTurn[]) {
|
||||
const event: HistoryEvent = { type, payload };
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new turn to the history.
|
||||
* Every turn must have a durable ID, usually provided by the ChatRecordingService.
|
||||
*/
|
||||
push(turn: HistoryTurn) {
|
||||
this.history.push(turn);
|
||||
this.notify('PUSH', [turn]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites the entire history with a new list of turns.
|
||||
*/
|
||||
set(turns: readonly HistoryTurn[], options: { silent?: boolean } = {}) {
|
||||
set(turns: readonly HistoryTurn[]) {
|
||||
this.history = [...turns];
|
||||
this.notify(options.silent ? 'SILENT_SYNC' : 'SYNC_FULL', this.history);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.history = [];
|
||||
this.notify('CLEAR', []);
|
||||
}
|
||||
|
||||
get(): readonly HistoryTurn[] {
|
||||
|
||||
@@ -1001,7 +1001,7 @@ ${JSON.stringify(
|
||||
{ model: 'default-routed-model', isChatModel: true },
|
||||
initialRequest,
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1872,7 +1872,7 @@ ${JSON.stringify(
|
||||
{ model: 'routed-model', isChatModel: true },
|
||||
[{ text: 'Hi' }],
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1890,7 +1890,7 @@ ${JSON.stringify(
|
||||
{ model: 'routed-model', isChatModel: true },
|
||||
[{ text: 'Hi' }],
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
|
||||
// Second turn
|
||||
@@ -1908,7 +1908,7 @@ ${JSON.stringify(
|
||||
{ model: 'routed-model', isChatModel: true },
|
||||
[{ text: 'Continue' }],
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1926,7 +1926,7 @@ ${JSON.stringify(
|
||||
{ model: 'routed-model', isChatModel: true },
|
||||
[{ text: 'Hi' }],
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
|
||||
// New prompt
|
||||
@@ -1948,7 +1948,7 @@ ${JSON.stringify(
|
||||
{ model: 'new-routed-model', isChatModel: true },
|
||||
[{ text: 'A new topic' }],
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1976,7 +1976,7 @@ ${JSON.stringify(
|
||||
{ model: 'original-model', isChatModel: true },
|
||||
[{ text: 'Hi' }],
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
|
||||
mockRouterService.route.mockResolvedValue({
|
||||
@@ -1999,7 +1999,7 @@ ${JSON.stringify(
|
||||
{ model: 'fallback-model', isChatModel: true },
|
||||
[{ text: 'Continue' }],
|
||||
expect.any(AbortSignal),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2428,7 +2428,7 @@ ${JSON.stringify(
|
||||
expect.objectContaining({ model: 'model-a' }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3469,7 +3469,7 @@ ${JSON.stringify(
|
||||
expect.anything(),
|
||||
[{ text: 'Please explain' }],
|
||||
expect.anything(),
|
||||
undefined,
|
||||
expect.objectContaining({ displayContent: undefined }),
|
||||
);
|
||||
|
||||
// First call should have stopHookActive=false, retry should have stopHookActive=true
|
||||
|
||||
@@ -53,7 +53,7 @@ import type {
|
||||
DefaultHookOutput,
|
||||
AfterAgentHookOutput,
|
||||
} from '../hooks/types.js';
|
||||
import { NextSpeakerCheckEvent, type LlmRole } from '../telemetry/types.js';
|
||||
import { NextSpeakerCheckEvent, LlmRole } from '../telemetry/types.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import type { IdeContext, File } from '../ide/types.js';
|
||||
import { handleFallback } from '../fallback/handler.js';
|
||||
@@ -389,9 +389,7 @@ export class GeminiClient {
|
||||
const toolDeclarations = toolRegistry.getFunctionDeclarations();
|
||||
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
|
||||
|
||||
const history = this.config.getContextManagementConfig().enabled
|
||||
? (extraHistory ?? [])
|
||||
: await getInitialChatHistory(this.config, extraHistory);
|
||||
const history = await getInitialChatHistory(this.config, extraHistory);
|
||||
|
||||
try {
|
||||
const systemMemory = this.config.getSystemInstructionMemory();
|
||||
@@ -640,21 +638,19 @@ export class GeminiClient {
|
||||
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
|
||||
|
||||
let currentBaseUnits = 0;
|
||||
let apiHistoryOverride: Content[] | undefined = undefined;
|
||||
|
||||
if (this.config.getContextManagementConfig().enabled) {
|
||||
if (this.contextManager) {
|
||||
const rawPendingRequest = createUserContent(request);
|
||||
const pendingRequest = {
|
||||
id:
|
||||
this.getChatRecordingService()?.recordSyntheticMessage(
|
||||
'user',
|
||||
rawPendingRequest.parts || [],
|
||||
) || randomUUID(),
|
||||
id: randomUUID(),
|
||||
content: rawPendingRequest,
|
||||
};
|
||||
const {
|
||||
history: newHistory,
|
||||
didApplyManagement,
|
||||
apiHistory,
|
||||
pendingApiHistory,
|
||||
baseUnits,
|
||||
} = await this.contextManager.renderHistory(
|
||||
pendingRequest,
|
||||
@@ -664,12 +660,22 @@ export class GeminiClient {
|
||||
|
||||
currentBaseUnits = baseUnits;
|
||||
|
||||
if (didApplyManagement) {
|
||||
// If the manager pruned history, we update the chat before continuing.
|
||||
// Note: we don't include the pendingRequest in this setHistory,
|
||||
// because Turn.run will add it normally.
|
||||
this.getChat().setHistory(newHistory, { silent: true });
|
||||
}
|
||||
// Use the PROCESSED pending content if available (e.g. if cleaned or distilled)
|
||||
const finalPendingContent =
|
||||
pendingApiHistory.length > 0
|
||||
? pendingApiHistory[0]
|
||||
: rawPendingRequest;
|
||||
|
||||
// Late-bind the prompt: Append the active request to the managed history
|
||||
// only for the purpose of the upcoming API call.
|
||||
apiHistoryOverride = [...apiHistory, finalPendingContent];
|
||||
|
||||
this.getChat().setHistory(newHistory);
|
||||
|
||||
// Use the original request for display/recording,
|
||||
// but the processed one for the API and durable history.
|
||||
displayContent = rawPendingRequest.parts || [];
|
||||
request = finalPendingContent.parts || [];
|
||||
} else {
|
||||
const newHistory = await this.agentHistoryProvider.manageHistory(
|
||||
this.getHistory(),
|
||||
@@ -794,12 +800,11 @@ export class GeminiClient {
|
||||
// Update tools with the final modelId to ensure model-dependent descriptions are used.
|
||||
await this.setTools(modelToUse);
|
||||
|
||||
const resultStream = turn.run(
|
||||
modelConfigKey,
|
||||
request,
|
||||
signal,
|
||||
const resultStream = turn.run(modelConfigKey, request, signal, {
|
||||
displayContent,
|
||||
);
|
||||
role: LlmRole.MAIN,
|
||||
apiHistoryOverride,
|
||||
});
|
||||
let isError = false;
|
||||
|
||||
let loopDetectedAbort = false;
|
||||
|
||||
@@ -2239,7 +2239,13 @@ describe('GeminiChat', () => {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'thinking...' },
|
||||
{ functionCall: { name: 'test', args: {} } },
|
||||
{
|
||||
functionCall: {
|
||||
name: 'test',
|
||||
args: {},
|
||||
id: expect.stringMatching(/^synth_test_/),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -51,8 +51,11 @@ import {
|
||||
} from '../telemetry/types.js';
|
||||
import { handleFallback } from '../fallback/handler.js';
|
||||
import { isFunctionResponse } from '../utils/messageInspectors.js';
|
||||
import { scrubHistory } from '../utils/historyHardening.js';
|
||||
import { partListUnionToString } from './geminiRequest.js';
|
||||
import { scrubHistory, scrubContents } from '../utils/historyHardening.js';
|
||||
import {
|
||||
partListUnionToString,
|
||||
ensureStableToolIds,
|
||||
} from '../utils/sessionUtils.js';
|
||||
import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
|
||||
@@ -62,7 +65,6 @@ import {
|
||||
} from '../availability/policyHelpers.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
export enum StreamEventType {
|
||||
/** A regular content chunk from the API. */
|
||||
@@ -312,6 +314,8 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
this.agentHistory = new AgentChatHistory(initialHistory);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ensureStableToolIds(this.agentHistory.get() as HistoryTurn[]);
|
||||
this.chatRecordingService = new ChatRecordingService(context);
|
||||
this.lastPromptTokenCount = estimateTokenCountSync(
|
||||
this.agentHistory.flatMap((c) => c.content.parts || []),
|
||||
@@ -325,7 +329,7 @@ export class GeminiChat {
|
||||
async initialize(
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
kind: 'main' | 'subagent' = 'main',
|
||||
) {
|
||||
): Promise<void> {
|
||||
await this.chatRecordingService.initialize(resumedSessionData, kind);
|
||||
// Sync initial history with the recorder to ensure all turns (even bootstrapped ones)
|
||||
// are durable and coordinated.
|
||||
@@ -375,6 +379,7 @@ export class GeminiChat {
|
||||
signal: AbortSignal,
|
||||
role: LlmRole,
|
||||
displayContent?: PartListUnion,
|
||||
apiHistoryOverride?: Content[],
|
||||
): Promise<AsyncGenerator<StreamEvent>> {
|
||||
await this.sendPromise;
|
||||
|
||||
@@ -388,6 +393,9 @@ export class GeminiChat {
|
||||
const { model } =
|
||||
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
|
||||
|
||||
const isContextManagementEnabled =
|
||||
this.context.config.isContextManagementEnabled();
|
||||
|
||||
// Record user input - capture complete message with all parts (text, files, images, etc.)
|
||||
// but skip recording function responses (tool call results) as they should be stored in tool call records
|
||||
if (!isFunctionResponse(userContent)) {
|
||||
@@ -405,13 +413,34 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
const id = this.chatRecordingService.recordMessage({
|
||||
model,
|
||||
type: 'user',
|
||||
content: userMessageParts,
|
||||
displayContent: finalDisplayContent,
|
||||
});
|
||||
this.agentHistory.push({ id, content: userContent });
|
||||
if (!isContextManagementEnabled) {
|
||||
const id = this.chatRecordingService.recordMessage({
|
||||
model,
|
||||
type: 'user',
|
||||
content: userMessageParts,
|
||||
displayContent: finalDisplayContent,
|
||||
});
|
||||
this.agentHistory.push({ id, content: userContent });
|
||||
} else {
|
||||
// With Context Management, the client has already recorded the user message
|
||||
// and called setHistory to ensure the graph is in sync.
|
||||
// We just verify it's there.
|
||||
const history = this.agentHistory.get();
|
||||
const lastTurn = history[history.length - 1];
|
||||
if (
|
||||
!lastTurn ||
|
||||
partListUnionToString(lastTurn.content.parts || []) !==
|
||||
userMessageContent
|
||||
) {
|
||||
const id = this.chatRecordingService.recordMessage({
|
||||
model,
|
||||
type: 'user',
|
||||
content: userMessageParts,
|
||||
displayContent: finalDisplayContent,
|
||||
});
|
||||
this.agentHistory.push({ id, content: userContent });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Record tool response as a message to ensure durable ID and linear history for resume.
|
||||
const id = this.chatRecordingService.recordSyntheticMessage(
|
||||
@@ -419,49 +448,63 @@ export class GeminiChat {
|
||||
userContent.parts || [],
|
||||
);
|
||||
|
||||
// Binary injections: If the tool output contains binary data, we expand the history.
|
||||
const binaryParts = this.extractBinaryInjections(userContent.parts);
|
||||
if (binaryParts) {
|
||||
// Turn 1: The original tool response (now cleaned)
|
||||
this.agentHistory.push({ id, content: userContent });
|
||||
if (!isContextManagementEnabled) {
|
||||
// Binary injections: If the tool output contains binary data, we expand the history.
|
||||
const binaryParts = this.extractBinaryInjections(userContent.parts);
|
||||
if (binaryParts) {
|
||||
// Turn 1: The original tool response (now cleaned)
|
||||
this.agentHistory.push({ id, content: userContent });
|
||||
|
||||
// Turn 2: Synthetic Model Acknowledgment
|
||||
const modelId = this.chatRecordingService.recordSyntheticMessage(
|
||||
'gemini',
|
||||
[
|
||||
{
|
||||
text: 'Binary content received. Proceeding with analysis.',
|
||||
thought: true,
|
||||
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
},
|
||||
],
|
||||
);
|
||||
this.agentHistory.push({
|
||||
id: modelId,
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
// Turn 2: Synthetic Model Acknowledgment
|
||||
const modelId = this.chatRecordingService.recordSyntheticMessage(
|
||||
'gemini',
|
||||
[
|
||||
{
|
||||
text: 'Binary content received. Proceeding with analysis.',
|
||||
thought: true,
|
||||
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
);
|
||||
this.agentHistory.push({
|
||||
id: modelId,
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'Binary content received. Proceeding with analysis.',
|
||||
thought: true,
|
||||
thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Turn 3: The actual binary data (becomes the current request message)
|
||||
const binaryId = this.chatRecordingService.recordSyntheticMessage(
|
||||
'info',
|
||||
binaryParts,
|
||||
);
|
||||
userContent = {
|
||||
role: 'user',
|
||||
parts: binaryParts,
|
||||
};
|
||||
this.agentHistory.push({ id: binaryId, content: userContent });
|
||||
// Turn 3: The actual binary data (becomes the current request message)
|
||||
const binaryId = this.chatRecordingService.recordSyntheticMessage(
|
||||
'info',
|
||||
binaryParts,
|
||||
);
|
||||
userContent = {
|
||||
role: 'user',
|
||||
parts: binaryParts,
|
||||
};
|
||||
this.agentHistory.push({ id: binaryId, content: userContent });
|
||||
} else {
|
||||
this.agentHistory.push({ id, content: userContent });
|
||||
}
|
||||
} else {
|
||||
this.agentHistory.push({ id, content: userContent });
|
||||
// With Context Management, we just push it to the history if not already there.
|
||||
// (The client should have handled this, but we're defensive).
|
||||
const history = this.agentHistory.get();
|
||||
const lastTurn = history[history.length - 1];
|
||||
if (
|
||||
!lastTurn ||
|
||||
partListUnionToString(lastTurn.content.parts || []) !==
|
||||
partListUnionToString(userContent.parts || [])
|
||||
) {
|
||||
this.agentHistory.push({ id, content: userContent });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +536,7 @@ export class GeminiChat {
|
||||
prompt_id,
|
||||
signal,
|
||||
role,
|
||||
apiHistoryOverride,
|
||||
);
|
||||
isConnectionPhase = false;
|
||||
for await (const chunk of stream) {
|
||||
@@ -635,6 +679,7 @@ export class GeminiChat {
|
||||
prompt_id: string,
|
||||
abortSignal: AbortSignal,
|
||||
role: LlmRole,
|
||||
apiHistoryOverride?: Content[],
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
// Last mile scrubbing to remove internal tracking properties (e.g. callIndex)
|
||||
// before sending to the Gemini API. This whitelists only standard Gemini fields.
|
||||
@@ -644,10 +689,12 @@ export class GeminiChat {
|
||||
|
||||
const scrubbedContents = scrubbedHistory.map((h) => h.content);
|
||||
|
||||
const contentsForPreviewModel =
|
||||
this.ensureActiveLoopHasThoughtSignatures(scrubbedContents);
|
||||
const requestContents = apiHistoryOverride
|
||||
? scrubContents(apiHistoryOverride)
|
||||
: scrubbedContents;
|
||||
|
||||
const requestContents = scrubbedContents;
|
||||
const contentsForPreviewModel =
|
||||
this.ensureActiveLoopHasThoughtSignatures(requestContents);
|
||||
|
||||
// Track final request parameters for AfterModel hooks
|
||||
const {
|
||||
@@ -934,12 +981,11 @@ export class GeminiChat {
|
||||
);
|
||||
this.agentHistory.push({ id, content });
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ensureStableToolIds(this.agentHistory.get() as HistoryTurn[]);
|
||||
}
|
||||
|
||||
setHistory(
|
||||
history: ReadonlyArray<Content | HistoryTurn>,
|
||||
options: { silent?: boolean } = {},
|
||||
): void {
|
||||
setHistory(history: ReadonlyArray<Content | HistoryTurn>): void {
|
||||
const wrappedHistory: HistoryTurn[] = history.map((item) => {
|
||||
if ('id' in item && 'content' in item) {
|
||||
return item;
|
||||
@@ -950,7 +996,8 @@ export class GeminiChat {
|
||||
);
|
||||
return { id, content: item };
|
||||
});
|
||||
this.agentHistory.set(wrappedHistory, options);
|
||||
ensureStableToolIds(wrappedHistory);
|
||||
this.agentHistory.set(wrappedHistory);
|
||||
this.lastPromptTokenCount = estimateTokenCountSync(
|
||||
this.agentHistory.flatMap((c) => c.content.parts || []),
|
||||
);
|
||||
@@ -1104,9 +1151,6 @@ export class GeminiChat {
|
||||
if (!id) {
|
||||
id = `synth_${this.context.promptId}_${Date.now()}_${this.callCounter++}`;
|
||||
callIndexToId.set(globalIndex, id);
|
||||
debugLogger.log(
|
||||
`[GeminiChat] Assigned synthetic ID: ${id} to tool at index ${globalIndex}: ${fnCall.name}`,
|
||||
);
|
||||
}
|
||||
fnCall.id = id;
|
||||
}
|
||||
@@ -1203,9 +1247,6 @@ export class GeminiChat {
|
||||
|
||||
let currentCallSourceIndex = -1;
|
||||
if (this.context.config.isContextManagementEnabled()) {
|
||||
debugLogger.log(
|
||||
`[GeminiChat] Starting consolidation for ${modelResponseParts.length} raw parts and ${finalFunctionCalls.length} assembled function calls.`,
|
||||
);
|
||||
for (const part of modelResponseParts) {
|
||||
if (part.functionCall) {
|
||||
const partIndex = isIndexedPart(part) ? part.callIndex : undefined;
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
StreamEventType,
|
||||
type GeminiChat,
|
||||
} from './geminiChat.js';
|
||||
import { LlmRole } from '../telemetry/types.js';
|
||||
|
||||
const mockSendMessageStream = vi.fn();
|
||||
const mockGetHistory = vi.fn();
|
||||
@@ -123,7 +122,8 @@ describe('Turn', () => {
|
||||
reqParts,
|
||||
'prompt-id-1',
|
||||
expect.any(AbortSignal),
|
||||
LlmRole.MAIN,
|
||||
'main',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import {
|
||||
createUserContent,
|
||||
type Content,
|
||||
type PartListUnion,
|
||||
type GenerateContentResponse,
|
||||
type FunctionCall,
|
||||
@@ -257,9 +258,13 @@ export class Turn {
|
||||
modelConfigKey: ModelConfigKey,
|
||||
req: PartListUnion,
|
||||
signal: AbortSignal,
|
||||
displayContent?: PartListUnion,
|
||||
role: LlmRole = LlmRole.MAIN,
|
||||
options: {
|
||||
displayContent?: PartListUnion;
|
||||
role?: LlmRole;
|
||||
apiHistoryOverride?: Content[];
|
||||
} = {},
|
||||
): AsyncGenerator<ServerGeminiStreamEvent> {
|
||||
const { displayContent, role = LlmRole.MAIN, apiHistoryOverride } = options;
|
||||
try {
|
||||
// Note: This assumes `sendMessageStream` yields events like
|
||||
// { type: StreamEventType.RETRY } or { type: StreamEventType.CHUNK, value: GenerateContentResponse }
|
||||
@@ -270,6 +275,7 @@ export class Turn {
|
||||
signal,
|
||||
role,
|
||||
displayContent,
|
||||
apiHistoryOverride,
|
||||
);
|
||||
|
||||
for await (const streamEvent of responseStream) {
|
||||
|
||||
@@ -191,6 +191,7 @@ describe('handleFallback', () => {
|
||||
expect(policyConfig.getFallbackModelHandler).not.toHaveBeenCalled();
|
||||
expect(policyConfig.activateFallbackMode).toHaveBeenCalledWith(
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
chainSpy.mockRestore();
|
||||
@@ -207,6 +208,9 @@ describe('handleFallback', () => {
|
||||
selectedModel: MOCK_PRO_MODEL,
|
||||
skipped: [],
|
||||
});
|
||||
// Mock activeModel to be unavailable so the utility bypass heuristic is skipped
|
||||
vi.mocked(availability.snapshot).mockReturnValue({ available: false });
|
||||
|
||||
policyHandler.mockResolvedValue('retry_once');
|
||||
|
||||
await handleFallback(
|
||||
@@ -351,6 +355,8 @@ describe('handleFallback', () => {
|
||||
vi.mocked(policyConfig.getModel).mockReturnValue(
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
);
|
||||
// Mock activeModel to be unavailable so the utility bypass heuristic is skipped
|
||||
vi.mocked(availability.snapshot).mockReturnValue({ available: false });
|
||||
|
||||
const result = await handleFallback(
|
||||
policyConfig,
|
||||
@@ -383,6 +389,7 @@ describe('handleFallback', () => {
|
||||
expect(result).toBe(true);
|
||||
expect(policyConfig.activateFallbackMode).toHaveBeenCalledWith(
|
||||
FALLBACK_MODEL,
|
||||
undefined,
|
||||
);
|
||||
// TODO: add logging expect statement
|
||||
});
|
||||
|
||||
@@ -42,8 +42,17 @@ export async function handleFallback(
|
||||
return { service: availability, policy: failedPolicy };
|
||||
};
|
||||
|
||||
const activeModel = config.getActiveModel();
|
||||
let fallbackModel: string;
|
||||
|
||||
if (!candidates.length) {
|
||||
if (
|
||||
failedModel !== activeModel &&
|
||||
availability.snapshot(activeModel).available
|
||||
) {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
return processIntent(config, 'retry_always', activeModel, failedModel);
|
||||
}
|
||||
fallbackModel = failedModel;
|
||||
} else {
|
||||
const selection = availability.selectFirstAvailable(
|
||||
@@ -70,9 +79,21 @@ export async function handleFallback(
|
||||
// failureKind is already declared and calculated above
|
||||
const action = resolvePolicyAction(failureKind, selectedPolicy);
|
||||
|
||||
if (action === 'silent') {
|
||||
if (
|
||||
action === 'silent' ||
|
||||
(fallbackModel === activeModel && failedModel !== activeModel)
|
||||
) {
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
return processIntent(config, 'retry_always', fallbackModel);
|
||||
// For standard auto-routing (silent), we only update the active model, so don't pass failedModel.
|
||||
// For utility bypass, we want a hard runtime override, so pass failedModel.
|
||||
const overrideFailedModel =
|
||||
failedModel !== activeModel ? failedModel : undefined;
|
||||
return processIntent(
|
||||
config,
|
||||
'retry_always',
|
||||
fallbackModel,
|
||||
overrideFailedModel,
|
||||
);
|
||||
}
|
||||
|
||||
// This will be used in the future when FallbackRecommendation is passed through UI
|
||||
@@ -103,7 +124,12 @@ export async function handleFallback(
|
||||
applyAvailabilityTransition(getAvailabilityContext, failureKind);
|
||||
}
|
||||
|
||||
return await processIntent(config, intent, fallbackModel);
|
||||
return await processIntent(
|
||||
config,
|
||||
intent,
|
||||
fallbackModel,
|
||||
failedModel !== activeModel ? failedModel : undefined,
|
||||
);
|
||||
} catch (handlerError) {
|
||||
debugLogger.error('Fallback handler failed:', handlerError);
|
||||
return null;
|
||||
@@ -131,12 +157,13 @@ async function processIntent(
|
||||
config: Config,
|
||||
intent: FallbackIntent | null,
|
||||
fallbackModel: string,
|
||||
failedModel?: string,
|
||||
): Promise<boolean> {
|
||||
switch (intent) {
|
||||
case 'retry_always':
|
||||
// TODO(telemetry): Implement generic fallback event logging. Existing
|
||||
// logFlashFallback is specific to a single Model.
|
||||
config.activateFallbackMode(fallbackModel);
|
||||
config.activateFallbackMode(fallbackModel, failedModel);
|
||||
return true;
|
||||
|
||||
case 'retry_once':
|
||||
|
||||
@@ -37,6 +37,12 @@ vi.mock('node:fs', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
vi.mock('node:os');
|
||||
vi.mock('undici', () => ({
|
||||
EnvHttpProxyAgent: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
setGlobalDispatcher: vi.fn(),
|
||||
Agent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ide-connection-utils', () => {
|
||||
beforeEach(() => {
|
||||
@@ -698,12 +704,36 @@ describe('ide-connection-utils', () => {
|
||||
});
|
||||
|
||||
describe('createProxyAwareFetch', () => {
|
||||
it('should return a proxy-aware fetcher function', async () => {
|
||||
it('should return a proxy-aware fetcher function that respects NO_PROXY and includes ideServerHost', async () => {
|
||||
const { createProxyAwareFetch } = await import(
|
||||
'./ide-connection-utils.js'
|
||||
);
|
||||
const fetcher = await createProxyAwareFetch('127.0.0.1');
|
||||
const { EnvHttpProxyAgent } = await import('undici');
|
||||
const ideServerHost = '127.0.0.1';
|
||||
const existingNoProxy = 'google.com,example.com';
|
||||
vi.stubEnv('NO_PROXY', existingNoProxy);
|
||||
|
||||
const fetcher = await createProxyAwareFetch(ideServerHost);
|
||||
expect(typeof fetcher).toBe('function');
|
||||
|
||||
expect(EnvHttpProxyAgent).toHaveBeenCalledWith({
|
||||
noProxy: `${existingNoProxy},${ideServerHost}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing NO_PROXY when creating proxy-aware fetcher', async () => {
|
||||
const { createProxyAwareFetch } = await import(
|
||||
'./ide-connection-utils.js'
|
||||
);
|
||||
const { EnvHttpProxyAgent } = await import('undici');
|
||||
const ideServerHost = 'host.docker.internal';
|
||||
vi.stubEnv('NO_PROXY', '');
|
||||
|
||||
await createProxyAwareFetch(ideServerHost);
|
||||
|
||||
expect(EnvHttpProxyAgent).toHaveBeenCalledWith({
|
||||
noProxy: ideServerHost,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,6 +93,8 @@ export * from './utils/sessionOperations.js';
|
||||
export * from './utils/planUtils.js';
|
||||
export * from './utils/approvalModeUtils.js';
|
||||
export * from './utils/fileDiffUtils.js';
|
||||
export * from './utils/path-validator.js';
|
||||
export * from './utils/atCommandUtils.js';
|
||||
export * from './utils/retry.js';
|
||||
export * from './utils/shell-utils.js';
|
||||
export {
|
||||
@@ -103,7 +105,6 @@ export {
|
||||
export * from './utils/tool-utils.js';
|
||||
export * from './utils/tool-visibility.js';
|
||||
export * from './utils/terminalSerializer.js';
|
||||
export * from './utils/systemEncoding.js';
|
||||
export * from './utils/textUtils.js';
|
||||
export * from './utils/formatters.js';
|
||||
export * from './utils/generateContentResponseUtilities.js';
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { Storage } from '../config/storage.js';
|
||||
import * as tomlLoader from './toml-loader.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { MCPServerConfig } from '../config/config.js';
|
||||
|
||||
vi.unmock('../config/storage.js');
|
||||
|
||||
@@ -279,6 +280,145 @@ describe('createPolicyEngineConfig', () => {
|
||||
expect(untrustedRule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT automatically allow configured MCP servers in non-interactive mode by default', async () => {
|
||||
const config = await createPolicyEngineConfig(
|
||||
{
|
||||
mcpServers: {
|
||||
'server-1': new MCPServerConfig('node', []),
|
||||
},
|
||||
},
|
||||
ApprovalMode.DEFAULT,
|
||||
MOCK_DEFAULT_DIR,
|
||||
false, // non-interactive
|
||||
);
|
||||
|
||||
const rule = config.rules?.find(
|
||||
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should automatically allow configured MCP servers in non-interactive mode if opted-in', async () => {
|
||||
const config = await createPolicyEngineConfig(
|
||||
{
|
||||
mcp: { autoAllowInHeadless: true },
|
||||
mcpServers: {
|
||||
'server-1': new MCPServerConfig('node', []),
|
||||
'server-2': new MCPServerConfig('python', []),
|
||||
},
|
||||
},
|
||||
ApprovalMode.DEFAULT,
|
||||
MOCK_DEFAULT_DIR,
|
||||
false, // non-interactive
|
||||
);
|
||||
|
||||
const rule1 = config.rules?.find(
|
||||
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
const rule2 = config.rules?.find(
|
||||
(r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
|
||||
expect(rule1).toBeDefined();
|
||||
expect(rule1?.source).toBe('Settings (Headless MCP Auto-Allow)');
|
||||
expect(rule2).toBeDefined();
|
||||
expect(rule2?.source).toBe('Settings (Headless MCP Auto-Allow)');
|
||||
});
|
||||
|
||||
it('should NOT automatically allow configured MCP servers in interactive mode even if opted-in', async () => {
|
||||
const config = await createPolicyEngineConfig(
|
||||
{
|
||||
mcp: { autoAllowInHeadless: true },
|
||||
mcpServers: {
|
||||
'server-1': new MCPServerConfig('node', []),
|
||||
},
|
||||
},
|
||||
ApprovalMode.DEFAULT,
|
||||
MOCK_DEFAULT_DIR,
|
||||
true, // interactive
|
||||
);
|
||||
|
||||
const rule = config.rules?.find(
|
||||
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT duplicate allow rules if an MCP server is already explicitly allowed, wildcard allowed, or trusted', async () => {
|
||||
const config = await createPolicyEngineConfig(
|
||||
{
|
||||
mcp: {
|
||||
autoAllowInHeadless: true,
|
||||
allowed: ['server-1', '*'],
|
||||
},
|
||||
mcpServers: {
|
||||
'server-1': new MCPServerConfig('node', []),
|
||||
'server-2': new MCPServerConfig('node', []),
|
||||
'server-3': { trust: true },
|
||||
'server-4': new MCPServerConfig('node', []),
|
||||
},
|
||||
},
|
||||
ApprovalMode.DEFAULT,
|
||||
MOCK_DEFAULT_DIR,
|
||||
false, // non-interactive
|
||||
);
|
||||
|
||||
// server-1: already in mcp.allowed
|
||||
const rules1 = config.rules?.filter(
|
||||
(r) => r.mcpName === 'server-1' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rules1).toHaveLength(1);
|
||||
expect(rules1?.[0].source).toBe('Settings (MCP Allowed)');
|
||||
|
||||
// server-2: covered by '*' in mcp.allowed
|
||||
// Note: the logic adds a rule for '*' which will match server-2 at runtime,
|
||||
// but the loop in headless auto-allow should skip adding a specific rule for server-2.
|
||||
const rules2 = config.rules?.filter(
|
||||
(r) => r.mcpName === 'server-2' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rules2).toHaveLength(0);
|
||||
|
||||
// server-3: already trusted
|
||||
const rules3 = config.rules?.filter(
|
||||
(r) => r.mcpName === 'server-3' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rules3).toHaveLength(1);
|
||||
expect(rules3?.[0].source).toBe('Settings (MCP Trusted)');
|
||||
|
||||
// server-4: NOT explicitly allowed or trusted, but SHOULD NOT be added because '*' exists in mcp.allowed
|
||||
const rules4 = config.rules?.filter(
|
||||
(r) => r.mcpName === 'server-4' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rules4).toHaveLength(0);
|
||||
|
||||
// Verify the wildcard rule exists
|
||||
const wildcardRule = config.rules?.find(
|
||||
(r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(wildcardRule).toBeDefined();
|
||||
expect(wildcardRule?.toolName).toBe('mcp_*');
|
||||
});
|
||||
|
||||
it('should use correct tool name pattern for wildcard server in headless auto-allow', async () => {
|
||||
const config = await createPolicyEngineConfig(
|
||||
{
|
||||
mcp: { autoAllowInHeadless: true },
|
||||
mcpServers: {
|
||||
'*': new MCPServerConfig('node', []),
|
||||
},
|
||||
},
|
||||
ApprovalMode.DEFAULT,
|
||||
MOCK_DEFAULT_DIR,
|
||||
false, // non-interactive
|
||||
);
|
||||
|
||||
const rule = config.rules?.find(
|
||||
(r) => r.mcpName === '*' && r.decision === PolicyDecision.ALLOW,
|
||||
);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule?.toolName).toBe('mcp_*');
|
||||
});
|
||||
|
||||
it('should handle multiple MCP server configurations together', async () => {
|
||||
const config = await createPolicyEngineConfig(
|
||||
{
|
||||
|
||||
@@ -600,6 +600,38 @@ export async function createPolicyEngineConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// In non-interactive mode, automatically allow all configured MCP servers if opted-in.
|
||||
// This ensures that tools provided by these servers are available without
|
||||
// requiring explicit entries in settings.mcp.allowed.
|
||||
if (
|
||||
!interactive &&
|
||||
settings.mcp?.autoAllowInHeadless &&
|
||||
settings.mcpServers
|
||||
) {
|
||||
for (const serverName of Object.keys(settings.mcpServers)) {
|
||||
// Avoid duplicates if already explicitly allowed, allowed via wildcard, or trusted.
|
||||
if (
|
||||
settings.mcp?.allowed?.includes(serverName) ||
|
||||
settings.mcp?.allowed?.includes('*') ||
|
||||
settings.mcpServers[serverName].trust
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.push({
|
||||
toolName:
|
||||
serverName === '*'
|
||||
? `${MCP_TOOL_PREFIX}*`
|
||||
: `${MCP_TOOL_PREFIX}${serverName}_*`,
|
||||
mcpName: serverName,
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: ALLOWED_MCP_SERVER_PRIORITY,
|
||||
source: 'Settings (Headless MCP Auto-Allow)',
|
||||
modes: nonPlanModes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rules,
|
||||
checkers,
|
||||
|
||||
@@ -333,6 +333,7 @@ export interface PolicySettings {
|
||||
mcp?: {
|
||||
excluded?: string[];
|
||||
allowed?: string[];
|
||||
autoAllowInHeadless?: boolean;
|
||||
};
|
||||
tools?: {
|
||||
core?: string[];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user