Compare commits

...

38 Commits

Author SHA1 Message Date
Aishanee Shah 5a88a5a8de fix: correctly import GoogleGenAI as a value 2026-05-14 21:05:14 +00:00
Aishanee Shah 72282e1ed9 fix: resolve GoogleGenAI runtime error and fix test isolation issues 2026-05-14 17:42:10 +00:00
Aishanee Shah 62e97b14a2 feat: implement explicit context caching for main agent with stable SI hashing 2026-05-12 20:15:52 +00:00
Aishanee Shah 5dda532573 feat: include full message records to link subagent trajectories 2026-05-11 22:14:59 +00:00
Aishanee Shah a1c9f55cf5 test: add test case for subagent trajectories in bug report 2026-05-11 20:54:11 +00:00
Aishanee Shah 69b336245c feat: include subagent trajectories in chat save and bug reports 2026-05-11 20:45:34 +00:00
Coco Sheng 36a7fa089c fix(cli): use static tool name in confirmation prompt to avoid parsing errors (#26866) 2026-05-11 17:45:58 +00:00
Sri Pasumarthi 4739495e39 fix(cli/acp): prevent infinite thought loop in ACP mode by disablig nextSpeakerCheck (#26874) 2026-05-11 17:38:20 +00:00
Aryan Singh ecfaac2dc7 fix(cli): prevent duplicate SessionStart systemMessage render (#25827)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-05-11 16:44:04 +00:00
Daniel Finimundi 7cd228f5af fix(cli): allow installing extensions from ssh repo (#26274)
Signed-off-by: Daniel Finimundi <danielrf@motorola.com>
Co-authored-by: Dev Randalpura <devrandalpura@google.com>
2026-05-11 15:57:52 +00:00
joshualitt 8a3fde4c33 fix(context): Change snapshotter model config. (#26745) 2026-05-11 15:06:55 +00:00
joshualitt 1a894c18ea feat(context): Improvements to the snapshotter. (#26655) 2026-05-08 23:54:44 +00:00
Adam Weidman 54f1e8c6d7 feat(core): add RemoteSubagentProtocol behind AgentProtocol (#25303) 2026-05-08 22:48:17 +00:00
krishdef7 f51391a0f2 fix(mcp): treat GET 404 as 405 in StreamableHTTPClientTransport (#24847)
Co-authored-by: Coco Sheng <cocosheng@google.com>
Co-authored-by: Spencer <spencertang@google.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-08 22:16:08 +00:00
Sri Pasumarthi 1238dcfe91 feat(acp/core): prefix tool call IDs with tool names to support tool rendering in ACP compliant IDEs. (#26676) 2026-05-08 21:21:54 +00:00
Coco Sheng 90e7155971 ci: implement codebase-aware effort level triage (#26666) 2026-05-08 20:48:54 +00:00
JAYADITYA 9d0860bd0f fix(cli): remove noisy theme registration logs from terminal (#25858)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2026-05-08 19:59:33 +00:00
Adam Weidman 014bfeb89b feat(core): add LocalSubagentProtocol behind AgentProtocol (#25302) 2026-05-08 19:28:16 +00:00
Aishanee Shah 5890f50496 fix(core): resolve parallel tool call streaming ID collision (#26646) 2026-05-08 19:14:23 +00:00
Daniel Weis 6b9b778d82 fix: resolve "function response turn must come immediately after function call" error (#26691)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-08 19:01:24 +00:00
Aishanee Shah f86e0ee418 fix(core): throw explicit error on dropped tool responses (#26668) 2026-05-08 18:36:39 +00:00
joshualitt 01635ddb83 fix(context): implement loose boundary policy for gc backstop. (#26594) 2026-05-08 17:36:57 +00:00
Adam Weidman 12c8469b34 refactor(core): agent session protocol changes (#26661) 2026-05-08 17:12:54 +00:00
gemini-cli-robot 43dda31549 Changelog for v0.41.0 (#26670)
Co-authored-by: g-samroberts <158088236+g-samroberts@users.noreply.github.com>
2026-05-08 17:00:48 +00:00
Dev Randalpura dfec94869b fix(ui): added quotes around session id in resume tip (#26669) 2026-05-08 16:55:51 +00:00
Christian Gunderman f42d4e3c16 fix(ci): fix maintainer identification in lifecycle manager (#26706) 2026-05-08 16:48:31 +00:00
ruomeng 838f6f8c18 docs(extensions): refactor releasing guide and add update mechanisms (#26595) 2026-05-08 16:20:22 +00:00
mahadevan 2cad5db770 Feat: Add Machine Hostname to CLI interface (#25637)
Signed-off-by: M-DEV-1 <mahadevankizhakkedathu@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-08 16:12:35 +00:00
Coco Sheng 3805640530 feat: export session to file and import via flag (#26514) 2026-05-08 15:53:52 +00:00
Tommaso Sciortino 2d10691acb skip broken test (#26705) 2026-05-08 15:47:08 +00:00
gemini-cli-robot 38a9dd18d3 Changelog for v0.42.0-preview.2 (#26597)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-05-08 00:53:59 +00:00
AK ebeea7570d fix(core): cache model routing decision in LocalAgentExecutor (#26548) 2026-05-08 00:18:22 +00:00
Br1an c52acebaa2 fix: prevent false command conflicts when launching from home directory (#23069)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-07 22:41:12 +00:00
Sandy Tao 16e345831b fix(cli): hide /memory add subcommand when memoryV2 is enabled (#26605) 2026-05-07 20:48:12 +00:00
Coco Sheng 3a0b19d900 ci: fix json parsing in scheduled triage workflow (#26656) 2026-05-07 20:35:20 +00:00
Dev Randalpura 451bf32c82 fix(ux): fixed issue with transcribed text not showing after releasing space (#26609) 2026-05-07 19:39:03 +00:00
Christopher Thomas 4fd411afe3 docs: add vi mode shortcuts and clarify MCP/custom sandbox setup (#23853)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-05-07 19:37:51 +00:00
Daniel Weis ac31e80984 fix(routing): fix resolveClassifierModel argument mismatch in ApprovalModeStrategy (#26658)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-07 19:34:14 +00:00
154 changed files with 9908 additions and 1399 deletions
+159
View File
@@ -0,0 +1,159 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
module.exports = async ({ github, context, core }) => {
const rawLabels = process.env.LABELS_OUTPUT;
core.info(`Raw labels JSON: ${rawLabels}`);
let parsedLabels;
try {
// First, try to parse the raw output as JSON.
parsedLabels = JSON.parse(rawLabels);
} catch (jsonError) {
// If that fails, check for a markdown code block.
core.warning(
`Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`,
);
const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
parsedLabels = JSON.parse(jsonMatch[1].trim());
} catch (markdownError) {
core.setFailed(
`Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawLabels}`,
);
return;
}
} else {
// If no markdown block, try to find a raw JSON array in the output.
// The CLI may include debug/log lines (e.g. telemetry init, YOLO mode)
// before the actual JSON response.
const jsonArrayMatch = rawLabels.match(
/\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/,
);
if (jsonArrayMatch) {
try {
parsedLabels = JSON.parse(jsonArrayMatch[0]);
} catch (extractError) {
// It's possible the regex matched from a `[STARTUP]` log all the way to the end
// of the JSON array. We need to be more aggressive and find the FIRST `[ { "issue_number"`
core.warning(
`Strict array match failed: ${extractError.message}. Attempting to clean leading noisy brackets.`,
);
const fallbackMatch = rawLabels.match(
/(\[\s*\{\s*"issue_number"[\s\S]*)/,
);
if (fallbackMatch) {
try {
// We might have grabbed trailing noise too, so we find the last closing bracket
const cleaned = fallbackMatch[0].substring(
0,
fallbackMatch[0].lastIndexOf(']') + 1,
);
parsedLabels = JSON.parse(cleaned);
} catch (fallbackError) {
core.setFailed(
`Found JSON-like content but failed to parse: ${fallbackError.message}\nRaw output: ${rawLabels}`,
);
return;
}
} else {
core.setFailed(
`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawLabels}`,
);
return;
}
}
} else {
core.setFailed(
`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawLabels}`,
);
return;
}
}
}
core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`);
for (const entry of parsedLabels) {
const issueNumber = entry.issue_number;
if (!issueNumber) {
core.info(
`Skipping entry with no issue number: ${JSON.stringify(entry)}`,
);
continue;
}
const labelsToAdd = entry.labels_to_add || [];
labelsToAdd.push('status/bot-triaged');
let labelsToRemove = entry.labels_to_remove || [];
labelsToRemove.push('status/need-triage');
// Deduplicate array
labelsToRemove = [...new Set(labelsToRemove)];
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd,
});
const explanation = entry.explanation ? ` - ${entry.explanation}` : '';
core.info(
`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`,
);
}
if (labelsToRemove.length > 0) {
for (const label of labelsToRemove) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: label,
});
} catch (e) {
if (e.status !== 404) {
core.warning(
`Failed to remove label ${label} from #${issueNumber}: ${e.message}`,
);
}
}
}
core.info(
`Successfully removed labels for #${issueNumber}: ${labelsToRemove.join(', ')}`,
);
}
if (entry.explanation || entry.effort_analysis) {
let commentBody = '';
if (entry.explanation) {
commentBody += entry.explanation;
}
if (entry.effort_analysis) {
if (commentBody) commentBody += '\n\n';
commentBody += `**Effort Analysis:**\n${entry.effort_analysis}`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: commentBody,
});
}
if (
(!entry.labels_to_add || entry.labels_to_add.length === 0) &&
(!entry.labels_to_remove || entry.labels_to_remove.length === 0)
) {
core.info(
`No labels to add or remove for #${issueNumber}, leaving as is`,
);
}
}
};
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
module.exports = async ({ github, context, core }) => {
let issuesToCleanup = [];
try {
const fileContent = fs.readFileSync('issues_to_cleanup.json', 'utf8');
issuesToCleanup = JSON.parse(fileContent);
} catch (error) {
if (error.code === 'ENOENT') {
core.info('No issues found to clean up.');
return;
}
core.setFailed(`Failed to read issues_to_cleanup.json: ${error.message}`);
return;
}
for (const issue of issuesToCleanup) {
try {
await github.rest.issues.removeLabel({
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}`,
);
} catch (error) {
if (error.status === 404) {
core.info(
`Label status/need-triage not found on #${issue.number}, skipping.`,
);
} else {
core.warning(
`Failed to remove label from #${issue.number}: ${error.message}`,
);
}
}
}
core.info(
`Cleaned up status/need-triage from ${issuesToCleanup.length} issues.`,
);
};
+38 -14
View File
@@ -41,6 +41,41 @@ module.exports = async ({ github, context, core }) => {
now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000,
);
const maintainerCache = new Map();
async function isMaintainer(user, association) {
if (user?.type === 'Bot') return true;
if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association)) return true;
const username = user?.login;
if (!username) return false;
if (maintainerCache.has(username)) {
return maintainerCache.get(username);
}
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username,
});
// Permission can be admin, write, read, none.
// Roles like 'maintain' or 'triage' often map to 'write' or 'read' in the top-level field.
const isM =
['admin', 'write'].includes(data.permission) ||
['admin', 'maintain', 'write'].includes(data.role_name);
maintainerCache.set(username, isM);
return isM;
} catch (err) {
core.warning(
`Could not check permissions for ${username}: ${err.message}`,
);
maintainerCache.set(username, false);
return false;
}
}
async function processItems(query, callback) {
core.info(`Searching: ${query}`);
try {
@@ -83,10 +118,7 @@ module.exports = async ({ github, context, core }) => {
const lastComment = comments[0];
if (
lastComment &&
!['OWNER', 'MEMBER', 'COLLABORATOR'].includes(
lastComment.author_association,
) &&
lastComment.user?.type !== 'Bot'
!(await isMaintainer(lastComment.user, lastComment.author_association))
) {
core.info(
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
@@ -188,11 +220,7 @@ module.exports = async ({ github, context, core }) => {
await processItems(
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`,
async (pr) => {
if (
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
pr.user?.type === 'Bot'
)
return;
if (await isMaintainer(pr.user, pr.author_association)) return;
core.info(`Nudging PR #${pr.number} for contribution policy.`);
if (!dryRun) {
@@ -216,11 +244,7 @@ module.exports = async ({ github, context, core }) => {
await processItems(
`repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"🔒 maintainer only" created:<${prCloseThreshold.toISOString()}`,
async (pr) => {
if (
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association) ||
pr.user?.type === 'Bot'
)
return;
if (await isMaintainer(pr.user, pr.author_association)) return;
core.info(
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
+99
View File
@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
module.exports = async ({ github, context, core }) => {
const query = `
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
issues(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
id
number
title
body
issueType {
name
}
labels(first: 20) {
nodes {
name
}
}
}
}
}
}
`;
try {
const result = await github.graphql(query, {
owner: context.repo.owner,
repo: context.repo.repo,
});
const issues = result.repository.issues.nodes;
const issuesNeedingAnalysis = [];
let syncedCount = 0;
for (const issue of issues) {
if (issue.issueType === null) {
const labelNames = issue.labels.nodes.map((l) => l.name);
const hasBug = labelNames.includes('kind/bug');
const hasFeature =
labelNames.includes('kind/feature') ||
labelNames.includes('kind/enhancement');
let issueTypeId = null;
if (hasBug) {
issueTypeId = 'IT_kwDOCaSVvs4BR7vP'; // Bug
} else if (hasFeature) {
issueTypeId = 'IT_kwDOCaSVvs4BR7vQ'; // Feature
}
if (issueTypeId) {
await github.graphql(
`
mutation($issueId: ID!, $issueTypeId: ID!) {
updateIssue(input: {id: $issueId, issueTypeId: $issueTypeId}) {
issue {
id
}
}
}
`,
{
issueId: issue.id,
issueTypeId: issueTypeId,
},
);
core.info(`Successfully synced Issue Type for #${issue.number}`);
syncedCount++;
} else {
// Needs analysis to determine kind/type
issuesNeedingAnalysis.push({
number: issue.number,
title: issue.title,
body: issue.body,
});
}
}
}
// Write issues needing analysis to a file so the AI can process them
fs.writeFileSync(
'no_type_issues.json',
JSON.stringify(issuesNeedingAnalysis),
);
core.info(`Synced ${syncedCount} issues from labels.`);
core.info(
`Found ${issuesNeedingAnalysis.length} issues missing both type and kind label to be analyzed.`,
);
} catch (error) {
core.setFailed(`Failed to sync issue types: ${error.message}`);
}
};
@@ -23,7 +23,7 @@ permissions:
jobs:
triage-issues:
timeout-minutes: 10
timeout-minutes: 60
if: |-
${{ github.repository == 'google-gemini/gemini-cli' }}
runs-on: 'ubuntu-latest'
@@ -51,6 +51,16 @@ jobs:
echo "has_issues=true" >> "${GITHUB_OUTPUT}"
echo "✅ Found issue #${{ github.event.issue.number }} from event to triage! 🎯"
- name: 'Sync Issue Types'
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 syncIssueTypes = require('./.github/scripts/sync-issue-types.cjs');
await syncIssueTypes({ github, context, core });
- name: 'Find untriaged issues'
if: |-
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
@@ -63,18 +73,22 @@ 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 100 --json number,title,body > no_area_issues.json
--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
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 100 --json number,title,body > no_kind_issues.json
--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
echo '🏷️ Finding issues missing priority labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 100 --json number,title,body > no_priority_issues.json
--search 'is:open is:issue -label:status/bot-triaged -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body > no_priority_issues.json
echo '📏 Finding issues missing effort labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:status/bot-triaged -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 > no_effort_issues.json
echo '🔄 Merging and deduplicating issues...'
jq -c -s 'add | unique_by(.number)' no_area_issues.json no_kind_issues.json no_priority_issues.json > issues_to_triage.json
jq -c -s 'add | unique_by(.number)' no_area_issues.json no_kind_issues.json no_priority_issues.json no_effort_issues.json no_type_issues.json > issues_to_triage.json
ISSUE_COUNT="$(jq 'length' issues_to_triage.json)"
if [ "$ISSUE_COUNT" -gt 0 ]; then
@@ -84,6 +98,22 @@ jobs:
fi
echo "✅ Found ${ISSUE_COUNT} unique issues to triage! 🎯"
- name: 'Create Gemini CLI Experiments Override'
if: |-
steps.get_issue_from_event.outputs.has_issues == 'true' || steps.find_issues.outputs.has_issues == 'true'
run: |
cat << 'EOF' > gemini_exp.json
{
"flags": [
{
"flagId": 45750526,
"boolValue": false
}
],
"experimentIds": []
}
EOF
- name: 'Get Repository Labels'
id: 'get_labels'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
@@ -109,6 +139,8 @@ jobs:
REPOSITORY: '${{ github.repository }}'
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
GEMINI_EXP: 'gemini_exp.json'
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -122,6 +154,8 @@ jobs:
"maxSessionTurns": 25,
"coreTools": [
"run_shell_command(echo)",
"grep_search",
"glob",
"read_file"
],
"telemetry": {
@@ -141,23 +175,26 @@ jobs:
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 "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.
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/* and priority/*.
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, priority/*, and effort/*.
5. Label Policy:
- If the issue already has a kind/ label, do not change it.
- If the issue already has a priority/ label, do not change it.
- If the issue already has an area/ label, do not change it.
- If the issue already has an effort/ label, do not change it.
- If the issue is missing an effort/ label AND its area is area/core, area/extensions, area/site, or area/non-interactive, you must evaluate the architectural complexity to determine the effort level. You MUST NOT guess the root cause. You MUST actively use your codebase search tools (grep_search and glob) to search for keywords from the issue and explore the codebase. You must identify the specific files and components involved before deciding the effort. Do NOT evaluate or assign an effort/ label to issues in any other areas (such as area/agent).
- If any of these are missing, select exactly ONE appropriate label for the missing category.
6. Identify other applicable labels based on the issue content, such as status/*, help wanted, good first issue, etc.
7. Give me a single short explanation about why you are selecting each label in the process.
8. Output a JSON array of objects, each containing the issue number
and the labels to add and remove, along with an explanation. For example:
and the labels to add and remove, along with an explanation. If you assigned an effort/ label, you MUST also include an effort_analysis field. This effort_analysis must be highly detailed, technical, and empirical. It MUST NOT contain vague guesses (e.g., avoid words like "likely points to" or "possibly"). You must explicitly cite the specific file paths and architectural mechanisms you discovered using your search tools, explain the root cause, and then explicitly state how that complexity maps to the chosen effort level guidelines. For example:
```
[
{
"issue_number": 123,
"labels_to_add": ["area/core", "kind/bug", "priority/p2"],
"labels_to_add": ["area/core", "kind/bug", "priority/p2", "effort/small"],
"labels_to_remove": ["status/need-triage"],
"explanation": "This issue is a UI bug that needs to be addressed with medium priority."
"explanation": "This issue is a UI bug that needs to be addressed with medium priority.",
"effort_analysis": "The `vscode-ide-companion` extension indiscriminately tracks active text editors via `vscode.window.onDidChangeActiveTextEditor` in `open-files-manager.ts`. When a user opens `.vscode/settings.json`, its content is sent to the CLI's context. The fix is highly localized to the VS Code companion extension's event listener. It involves adding a simple conditional check to exclude specific configuration files from the active editor tracking logic, which is a trivial logic adjustment with a clear root cause."
}
]
```
@@ -181,6 +218,23 @@ jobs:
- Identify only one priority/ label.
- 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.
Categorization Guidelines (Effort):
effort/small (1 day or less):
- Trivial Logic & Config: Schema updates (Zod), feature flag toggles, adding missing fields to package.json or settings.json.
- UI/Aesthetic Adjustments: Fixing minor layout bugs in Ink components (e.g., adding flexShrink, correcting padding in a single Box), text color changes.
- Documentation & Strings: Typos, log message updates, CLI argument descriptions.
- Localized Bug Fixes: Single-file logic errors, straightforward promise rejections (e.g., wrapping a known failure in a try/catch), simple regex or string parsing fixes.
effort/medium (2-3 days):
- React/Ink State Management: Debugging useState/useEffect/useReducer bugs, component lifecycle issues (memory leaks in the UI), terminal redraw flickering, or state synchronization between the CLI's internal input buffer and the interactive React components.
- Asynchronous Flow & Integration: Resolving complex Promise chains, ERR_STREAM_PREMATURE_CLOSE, debugging IDE companion extensions (VS Code, Android Studio) or resolving hanging HTTP requests/IPC between the CLI and external plugins, timeouts in non-interactive/ACP modes.
- Tooling & Output Parsers: Modifying how tools parse streaming stdout/stderr buffers, adding new built-in tools that don't require native bindings.
- Cross-Component Refactors: Changes that span across packages/cli and packages/core to pass new data models or telemetry state.
effort/large (3+ days):
- Platform-Specific Complexities (PTY/Signals): Any issue involving node-pty, child_process.spawn, OS-level shell behavior (Windows vs Linux vs macOS), pseudo-terminal exhaustion (ENXIO), raw mode terminal desyncs, or POSIX signal forwarding (SIGINT/SIGTERM).
- Core Architecture & Protocols: Refactoring the Scheduler, Agent-to-Agent (A2A) protocol implementation, low-level MCP (Model Context Protocol) transport mechanisms.
- Performance & Memory: Diagnosing massive disk/memory leaks, severe boot time regressions, high-throughput streaming optimizations (e.g., voice streaming pipelines).
Note: Any bug that is described as intermittent, flickering, difficult to reproduce, platform-specific, or requiring cross-environment setups (e.g., involving the VS Code IDE companion, GCA plugin, or Android Studio) MUST NOT be rated as effort/small because of the increased overhead of testing and reproducing.
Categorization Guidelines (Priority):
P0 - Urgent Blocking Issues:
- DO NOT APPLY THIS LABEL AUTOMATICALLY. Use status/manual-triage instead.
@@ -229,53 +283,37 @@ jobs:
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const rawLabels = process.env.LABELS_OUTPUT;
core.info(`Raw labels JSON: ${rawLabels}`);
let parsedLabels;
try {
const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/);
if (!jsonMatch || !jsonMatch[1]) {
throw new Error("Could not find a ```json ... ``` block in the output.");
}
const jsonString = jsonMatch[1].trim();
parsedLabels = JSON.parse(jsonString);
core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`);
} catch (err) {
core.setFailed(`Failed to parse labels JSON from Gemini output: ${err.message}\nRaw output: ${rawLabels}`);
return;
}
const applyLabels = require('./.github/scripts/apply-issue-labels.cjs');
await applyLabels({ github, context, core });
for (const entry of parsedLabels) {
const issueNumber = entry.issue_number;
if (!issueNumber) {
core.info(`Skipping entry with no issue number: ${JSON.stringify(entry)}`);
continue;
}
- name: 'Sync Issue Types (Post-Analysis)'
if: |-
always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const syncIssueTypes = require('./.github/scripts/sync-issue-types.cjs');
await syncIssueTypes({ github, context, core });
const labelsToAdd = entry.labels_to_add || [];
labelsToAdd.push('status/bot-triaged');
- name: 'Find Triaged Issues to Clean Up'
if: |-
always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
env:
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |-
set -euo pipefail
echo '🧹 Finding issues that have both bot-triaged and need-triage 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
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd
});
const explanation = entry.explanation ? ` - ${entry.explanation}` : '';
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`);
}
if (entry.explanation) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: entry.explanation,
});
}
if ((!entry.labels_to_add || entry.labels_to_add.length === 0) && (!entry.labels_to_remove || entry.labels_to_remove.length === 0)) {
core.info(`No labels to add or remove for #${issueNumber}, leaving as is`);
}
}
- name: 'Clean Up Triage Labels'
if: |-
always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const cleanupLabels = require('./.github/scripts/cleanup-triage-labels.cjs');
await cleanupLabels({ github, context, core });
+14
View File
@@ -18,6 +18,20 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.41.0 - 2026-05-05
- **Real-time Voice Mode:** Implemented real-time voice mode with cloud and
local backends
([#24174](https://github.com/google-gemini/gemini-cli/pull/24174) by
@Abhijit-2592).
- **Secure Environment Loading:** Enforced workspace trust and secured .env
loading in headless mode
([#25814](https://github.com/google-gemini/gemini-cli/pull/25814) by
@ehedlund).
- **Advanced Shell Validation:** Enhanced shell command validation and added
core tools allowlist for improved security
([#25720](https://github.com/google-gemini/gemini-cli/pull/25720) by @galz10).
## Announcements: v0.40.0 - 2026-04-28
- **Offline Search and Themes:** Bundled ripgrep for offline search support and
+108 -166
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.40.0
# Latest stable release: v0.41.0
Released: April 28, 2026
Released: May 05, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,177 +11,119 @@ npm install -g @google/gemini-cli
## Highlights
- **Offline Search Support:** Bundled ripgrep binaries into the Single
Executable Application (SEA) to enable powerful codebase searching even in
environments without internet access.
- **Enhanced Theme Customization:** Introduced GitHub-style colorblind-friendly
themes to improve accessibility and provide more personalized visual options.
- **MCP Resource Management:** Added new tools for listing and reading Model
Context Protocol (MCP) resources, enhancing the agent's ability to discover
and utilize external data.
- **Improved Narrative Flow:** Enabled topic update narrations by default to
provide better session structure and a clearer understanding of the agent's
current focus.
- **Streamlined Local Model Setup:** Introduced a simplified `gemini gemma`
command for quickly setting up and running Gemma models locally.
- **Prompt-Driven Memory Management:** Replaced the legacy `MemoryManagerAgent`
with a more efficient prompt-driven memory editing system across four tiers of
context.
- **Real-time Voice Mode:** Introduced support for real-time voice interaction
with both cloud-based and local processing backends.
- **Enhanced Security:** Implemented mandatory workspace trust for headless
environments and secured the loading of `.env` configuration files.
- **Advanced Shell Validation:** Added a robust shell command validation layer
and a core tools allowlist to prevent unauthorized execution.
- **Improved Context Management:** Integrated a new `ContextManager` and
`AgentChatHistory` to provide more reliable and efficient session handling.
- **Auto-Memory Persistence:** Enabled the persistence of the auto-memory
scratchpad, allowing for seamless skill extraction across turns.
## What's Changed
- chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a by
- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by
@gemini-cli-robot in
[#25420](https://github.com/google-gemini/gemini-cli/pull/25420)
- Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075)
by @rcleveng in
[#25187](https://github.com/google-gemini/gemini-cli/pull/25187)
- fix(core): prevent YOLO mode from being downgraded by @galz10 in
[#25341](https://github.com/google-gemini/gemini-cli/pull/25341)
- feat: bundle ripgrep binaries into SEA for offline support by @scidomino in
[#25342](https://github.com/google-gemini/gemini-cli/pull/25342)
- Changelog for v0.39.0-preview.0 by @gemini-cli-robot in
[#25417](https://github.com/google-gemini/gemini-cli/pull/25417)
- feat(test): add large conversation scenario for performance test by
[#25847](https://github.com/google-gemini/gemini-cli/pull/25847)
- fix(core): only show `list` suggestion if the partial input is empty by
@cynthialong0-0 in
[#25331](https://github.com/google-gemini/gemini-cli/pull/25331)
- improve(core): require recurrence evidence before extracting skills by
[#25821](https://github.com/google-gemini/gemini-cli/pull/25821)
- feat(cli): secure .env loading and enforce workspace trust in headless mode by
@ehedlund in [#25814](https://github.com/google-gemini/gemini-cli/pull/25814)
- fix: fatal hard-crash on loop detection via unhandled AbortError by @hsm207 in
[#20108](https://github.com/google-gemini/gemini-cli/pull/20108)
- update package-lock.json by @ehedlund in
[#25876](https://github.com/google-gemini/gemini-cli/pull/25876)
- feat(core): enhance shell command validation and add core tools allowlist by
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
- fix(ui): corrected background color check in user message components by
@devr0306 in [#25880](https://github.com/google-gemini/gemini-cli/pull/25880)
- perf(core): fix slow boot by fetching experiments and quota asynchronously by
@spencer426 in
[#25758](https://github.com/google-gemini/gemini-cli/pull/25758)
- feat(core,cli): add support for Gemma 4 models (experimental) by @Abhijit-2592
in [#25604](https://github.com/google-gemini/gemini-cli/pull/25604)
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
- docs: add Gemini CLI course link to README by @JayadityaGit in
[#25925](https://github.com/google-gemini/gemini-cli/pull/25925)
- feat(repo): add gemini-cli-bot metrics and workflows by @gundermanc in
[#25888](https://github.com/google-gemini/gemini-cli/pull/25888)
- fix(cli): allow output redirection for cli commands by @spencer426 in
[#25894](https://github.com/google-gemini/gemini-cli/pull/25894)
- fix(core): fail closed in YOLO mode when shell parsing fails for restricted
rules by @ehedlund in
[#25935](https://github.com/google-gemini/gemini-cli/pull/25935)
- fix(cli-ui): revert backspace handling to fix Windows regression by @scidomino
in [#25941](https://github.com/google-gemini/gemini-cli/pull/25941)
- feat(voice): implement real-time voice mode with cloud and local backends by
@Abhijit-2592 in
[#24174](https://github.com/google-gemini/gemini-cli/pull/24174)
- Changelog for v0.39.0 by @gemini-cli-robot in
[#25848](https://github.com/google-gemini/gemini-cli/pull/25848)
- feat(memory): persist auto-memory scratchpad for skill extraction by
@SandyTao520 in
[#25147](https://github.com/google-gemini/gemini-cli/pull/25147)
- test(evals): add subagent delegation evaluation tests by @anj-s in
[#24619](https://github.com/google-gemini/gemini-cli/pull/24619)
- feat: add github colorblind themes by @Z1xus in
[#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
- fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL by
@chrisjcthomas in
[#25357](https://github.com/google-gemini/gemini-cli/pull/25357)
- fix(cli): clean up slash command IDE listeners by @jasonmatthewsuhari in
[#24397](https://github.com/google-gemini/gemini-cli/pull/24397)
- Changelog for v0.38.0 by @gemini-cli-robot in
[#25470](https://github.com/google-gemini/gemini-cli/pull/25470)
- fix(evals): update eval tests for invoke_agent telemetry and project-scoped
memory by @SandyTao520 in
[#25502](https://github.com/google-gemini/gemini-cli/pull/25502)
- Changelog for v0.38.1 by @gemini-cli-robot in
[#25476](https://github.com/google-gemini/gemini-cli/pull/25476)
- feat(core): integrate skill-creator into skill extraction agent by
@SandyTao520 in
[#25421](https://github.com/google-gemini/gemini-cli/pull/25421)
- feat(cli): provide default post-submit prompt for skill command by @ruomengz
in [#25327](https://github.com/google-gemini/gemini-cli/pull/25327)
- feat(core): add tools to list and read MCP resources by @ruomengz in
[#25395](https://github.com/google-gemini/gemini-cli/pull/25395)
- fix(evals): add typecheck coverage for evals, integration-tests, and
memory-tests by @SandyTao520 in
[#25480](https://github.com/google-gemini/gemini-cli/pull/25480)
- Use OSC 777 for terminal notifications by @jackyliuxx in
[#25300](https://github.com/google-gemini/gemini-cli/pull/25300)
- fix(extensions): fix bundling for examples by @abhipatel12 in
[#25542](https://github.com/google-gemini/gemini-cli/pull/25542)
- fix(cli): reset plan session state on /clear by @jasonmatthewsuhari in
[#25515](https://github.com/google-gemini/gemini-cli/pull/25515)
- feat(core): add .mdx support to get-internal-docs tool by @g-samroberts in
[#25090](https://github.com/google-gemini/gemini-cli/pull/25090)
- docs(policy): mention that workspace policies are broken by @6112 in
[#24367](https://github.com/google-gemini/gemini-cli/pull/24367)
- fix(core): allow explicit write permissions to override governance file
protections in sandboxes by @galz10 in
[#25338](https://github.com/google-gemini/gemini-cli/pull/25338)
- feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first by
@mvanhorn in [#25427](https://github.com/google-gemini/gemini-cli/pull/25427)
- Reduce blank lines. by @gundermanc in
[#25563](https://github.com/google-gemini/gemini-cli/pull/25563)
- fix(ui): revert preview theme on dialog unmount by @JayadityaGit in
[#22542](https://github.com/google-gemini/gemini-cli/pull/22542)
- fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save
backoff by @mahimashanware in
[#25382](https://github.com/google-gemini/gemini-cli/pull/25382)
- feat(core): Disable topic updates for subagents by @gundermanc in
[#25567](https://github.com/google-gemini/gemini-cli/pull/25567)
- feat(core): enable topic update narration by default and promote to general by
@gundermanc in
[#25586](https://github.com/google-gemini/gemini-cli/pull/25586)
- docs: migrate installation and authentication to mdx with tabbed layouts by
@g-samroberts in
[#25155](https://github.com/google-gemini/gemini-cli/pull/25155)
- feat(config): split memoryManager flag into autoMemory by @SandyTao520 in
[#25601](https://github.com/google-gemini/gemini-cli/pull/25601)
- fix(core): allow Cloud Shell users to use PRO_MODEL_NO_ACCESS experiment by
@sehoon38 in [#25702](https://github.com/google-gemini/gemini-cli/pull/25702)
- fix(cli): round slow render latency to avoid opentelemetry float warning by
@scidomino in [#25709](https://github.com/google-gemini/gemini-cli/pull/25709)
- docs(tracker): introduce experimental task tracker feature by @anj-s in
[#24556](https://github.com/google-gemini/gemini-cli/pull/24556)
- docs(cli): fix inconsistent system.md casing in system prompt docs by @Bodlux
in [#25414](https://github.com/google-gemini/gemini-cli/pull/25414)
- feat(cli): add streamlined `gemini gemma` local model setup by @Samee24 in
[#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
- Changelog for v0.38.2 by @gemini-cli-robot in
[#25593](https://github.com/google-gemini/gemini-cli/pull/25593)
- Fix: Disallow overriding IDE stdio via workspace .env (RCE) by @M0nd0R in
[#25022](https://github.com/google-gemini/gemini-cli/pull/25022)
- feat(test): refactor the memory usage test to use metrics from CLI process
instead of test runner by @cynthialong0-0 in
[#25708](https://github.com/google-gemini/gemini-cli/pull/25708)
- feat(vertex): add settings for Vertex AI request routing by @gordonhwc in
[#25513](https://github.com/google-gemini/gemini-cli/pull/25513)
- Fix/allow for session persistence by @ahsanfarooq210 in
[#25176](https://github.com/google-gemini/gemini-cli/pull/25176)
- Allow dots on GEMINI_API_KEY by @DKbyo in
[#25497](https://github.com/google-gemini/gemini-cli/pull/25497)
- feat(telemetry): add flag for enabling traces specifically by @spencer426 in
[#25343](https://github.com/google-gemini/gemini-cli/pull/25343)
- fix(core): resolve nested plan directory duplication and relative path
policies by @mahimashanware in
[#25138](https://github.com/google-gemini/gemini-cli/pull/25138)
- feat: detect new files in @ recommendations with watcher based updates by
@prassamin in [#25256](https://github.com/google-gemini/gemini-cli/pull/25256)
- fix(cli): use newline in shell command wrapping to avoid breaking heredocs by
[#25873](https://github.com/google-gemini/gemini-cli/pull/25873)
- fix(cli): add missing response key to custom theme text schema by @gaurav0107
in [#25822](https://github.com/google-gemini/gemini-cli/pull/25822)
- fix(cli): provide manual update command when automatic update fails by
@cocosheng-g in
[#25537](https://github.com/google-gemini/gemini-cli/pull/25537)
- fix(cli): ensure theme dialog labels are rendered for all themes by
@JayadityaGit in
[#24599](https://github.com/google-gemini/gemini-cli/pull/24599)
- fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child
processes by @euxaristia in
[#22620](https://github.com/google-gemini/gemini-cli/pull/22620)
- feat: add /new as alias for /clear and refine command description by @ved015
in [#17865](https://github.com/google-gemini/gemini-cli/pull/17865)
- fix(cli): start auto memory in ACP sessions by @jasonmatthewsuhari in
[#25626](https://github.com/google-gemini/gemini-cli/pull/25626)
- fix(core): remove duplicate initialize call on agents refreshed by
@adamfweidman in
[#25670](https://github.com/google-gemini/gemini-cli/pull/25670)
- test(e2e): default integration tests to Flash Preview by @SandyTao520 in
[#25753](https://github.com/google-gemini/gemini-cli/pull/25753)
- refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing
across four tiers by @SandyTao520 in
[#25716](https://github.com/google-gemini/gemini-cli/pull/25716)
- fix(cli): fix "/clear (new)" command by @mini2s in
[#25801](https://github.com/google-gemini/gemini-cli/pull/25801)
- fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0'
by @thekishandev in
[#24414](https://github.com/google-gemini/gemini-cli/pull/24414)
- fix(core): handle line endings in ignore file parsing by @xoma-zver in
[#23895](https://github.com/google-gemini/gemini-cli/pull/23895)
- Fix/command injection shell by @Famous077 in
[#24170](https://github.com/google-gemini/gemini-cli/pull/24170)
- fix(ui): removed background color for input by @devr0306 in
[#25339](https://github.com/google-gemini/gemini-cli/pull/25339)
- fix(devtools): reduce memory usage and defer connection by @SandyTao520 in
[#24496](https://github.com/google-gemini/gemini-cli/pull/24496)
- fix(core): support jsonl session logs in memory and summary services by
@SandyTao520 in
[#25816](https://github.com/google-gemini/gemini-cli/pull/25816)
- fix(release): exclude ripgrep binaries from npm tarballs by @SandyTao520 in
[#25841](https://github.com/google-gemini/gemini-cli/pull/25841)
- fix(patch): cherry-pick 048bf6e to release/v0.40.0-preview.3-pr-25941 to patch
version v0.40.0-preview.3 and create version 0.40.0-preview.4 by
[#26052](https://github.com/google-gemini/gemini-cli/pull/26052)
- test(cli): add unit tests for restore ACP command (#23402) by @cocosheng-g in
[#26053](https://github.com/google-gemini/gemini-cli/pull/26053)
- fix(ui): better error messages for ECONNRESET and ETIMEDOUT by @devr0306 in
[#26059](https://github.com/google-gemini/gemini-cli/pull/26059)
- feat(core): wire up the new ContextManager and AgentChatHistory by @joshualitt
in [#25409](https://github.com/google-gemini/gemini-cli/pull/25409)
- fix(cli): ensure sandbox proxy cleanup and remove handler leaks by @ehedlund
in [#26065](https://github.com/google-gemini/gemini-cli/pull/26065)
- fix(cli): correct alternate buffer warning logic for JetBrains by @Adib234 in
[#26067](https://github.com/google-gemini/gemini-cli/pull/26067)
- fix(cli): make MCP ping optional in list command and use configured timeout by
@cocosheng-g in
[#26068](https://github.com/google-gemini/gemini-cli/pull/26068)
- fix(core): better error message for failed cloudshell-gca auth by @devr0306 in
[#26079](https://github.com/google-gemini/gemini-cli/pull/26079)
- feat(cli): provide manual session UUID via command line arg by @cocosheng-g in
[#26060](https://github.com/google-gemini/gemini-cli/pull/26060)
- Changelog for v0.40.0-preview.2 by @gemini-cli-robot in
[#25846](https://github.com/google-gemini/gemini-cli/pull/25846)
- (docs) update sandboxing documentation by @g-samroberts in
[#25930](https://github.com/google-gemini/gemini-cli/pull/25930)
- fix(core): enforce parallel task tracker updates by @anj-s in
[#24477](https://github.com/google-gemini/gemini-cli/pull/24477)
- Update policy so transient errors are not marked terminal by @DavidAPierce in
[#26066](https://github.com/google-gemini/gemini-cli/pull/26066)
- Implement bot that performs time-series metric analysis and suggests repo
management improvements by @gundermanc in
[#25945](https://github.com/google-gemini/gemini-cli/pull/25945)
- fix(core): handle non-string model flags in resolution by @Adib234 in
[#26069](https://github.com/google-gemini/gemini-cli/pull/26069)
- fix(ux): added error message for ENOTDIR by @devr0306 in
[#26128](https://github.com/google-gemini/gemini-cli/pull/26128)
- Changelog for v0.40.0-preview.3 by @gemini-cli-robot in
[#25904](https://github.com/google-gemini/gemini-cli/pull/25904)
- fix(cli): prevent ACP stdout pollution from SessionEnd hooks by @cocosheng-g
in [#26125](https://github.com/google-gemini/gemini-cli/pull/26125)
- feat(cli): support boolean and number casting for env vars in settings.json by
@cocosheng-g in
[#26118](https://github.com/google-gemini/gemini-cli/pull/26118)
- fix(cli): preserve Request headers in DevTools activity logger by @Adib234 in
[#26078](https://github.com/google-gemini/gemini-cli/pull/26078)
- fix(patch): cherry-pick 2194da2 to release/v0.41.0-preview.0-pr-26153 to patch
version v0.41.0-preview.0 and create version 0.41.0-preview.1 by
@gemini-cli-robot in
[#25942](https://github.com/google-gemini/gemini-cli/pull/25942)
- fix(patch): cherry-pick 54b7586 to release/v0.40.0-preview.4-pr-26066
[CONFLICTS] by @gemini-cli-robot in
[#26124](https://github.com/google-gemini/gemini-cli/pull/26124)
[#26269](https://github.com/google-gemini/gemini-cli/pull/26269)
- fix(patch): cherry-pick 1d72a12 to release/v0.41.0-preview.1-pr-26479 to patch
version v0.41.0-preview.1 and create version 0.41.0-preview.2 by
@gemini-cli-robot in
[#26508](https://github.com/google-gemini/gemini-cli/pull/26508)
- fix(patch): cherry-pick 7cc19c2 to release/v0.41.0-preview.2-pr-26507 to patch
version v0.41.0-preview.2 and create version 0.41.0-preview.3 by
@gemini-cli-robot in
[#26530](https://github.com/google-gemini/gemini-cli/pull/26530)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.39.1...v0.40.0
https://github.com/google-gemini/gemini-cli/compare/v0.40.1...v0.41.0
+3 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.42.0-preview.0
# Preview release: v0.42.0-preview.2
Released: May 05, 2026
Released: May 06, 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).
@@ -242,4 +242,4 @@ npm install -g @google/gemini-cli@preview
[#26357](https://github.com/google-gemini/gemini-cli/pull/26357)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.41.0-preview.3...v0.42.0-preview.0
https://github.com/google-gemini/gemini-cli/compare/v0.41.0-preview.3...v0.42.0-preview.2
+1 -1
View File
@@ -169,7 +169,7 @@ they appear in the UI.
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. Note: When using the Gemini Live backend, voice recordings are sent to Google Cloud for transcription. | `"gemini-live"` |
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `1000` |
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `4000` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
+57 -23
View File
@@ -1,7 +1,8 @@
# Release extensions
Release Gemini CLI extensions to your users through a Git repository or GitHub
Releases.
Releases. This guide explains how to share your work, list it in the gallery,
and manage updates.
Git repository releases are the simplest approach and offer the most flexibility
for managing development branches. GitHub Releases are more efficient for
@@ -153,29 +154,62 @@ jobs:
release/win32.arm64.my-tool.zip
```
## Migrating an Extension Repository
## Migrate an extension repository
If you need to move your extension to a new repository (for example, from a
personal account to an organization) or rename it, you can use the `migratedTo`
property in your `gemini-extension.json` file to seamlessly transition your
If you move your extension to a new repository or rename it, use the
`migratedTo` property in `gemini-extension.json` to seamlessly transition your
users.
1. **Create the new repository**: Setup your extension in its new location.
2. **Update the old repository**: In your original repository, update the
`gemini-extension.json` file to include the `migratedTo` property, pointing
to the new repository URL, and bump the version number. You can optionally
change the `name` of your extension at this time in the new repository.
```json
{
"name": "my-extension",
"version": "1.1.0",
"migratedTo": "https://github.com/new-owner/new-extension-repo"
}
```
3. **Release the update**: Publish this new version in your old repository.
1. **Create the new repository:** Set up your extension in its new location.
2. **Update the old repository:** In your original repository, update the
`gemini-extension.json` file to include the `migratedTo` property pointing
to the new repository URL, and increment the version number.
```json
{
"name": "my-extension",
"version": "1.1.0",
"migratedTo": "https://github.com/new-owner/new-extension-repo"
}
```
3. **Release the update:** Publish this new version in your old repository.
When users check for updates, Gemini CLI will detect the `migratedTo` field,
verify that the new repository contains a valid extension update, and
automatically update their local installation to track the new source and name
moving forward. All extension settings will automatically migrate to the new
installation.
When users check for updates, Gemini CLI detects the `migratedTo` field,
verifies the new repository, and automatically updates their local installation
to track the new source. All settings migrate automatically.
## How updates work
Gemini CLI automatically checks for extension updates based on the installation
method. Understanding these mechanisms helps you ensure your users always have
the latest version.
### Sync manifest and tags
For GitHub releases, always ensure the `version` in `gemini-extension.json`
matches your GitHub release tag. While the CLI uses tags for update detection,
it displays the manifest version in the UI. Keeping them in sync prevents
confusion.
### Update mechanisms
<details>
<summary>Technical update details</summary>
The CLI uses different strategies depending on the installation type:
- **GitHub releases:** The CLI queries the GitHub API for the latest release
tag. It ignores the `version` field in the manifest for detection.
- **Git clones:** The CLI runs `git ls-remote` to compare the latest remote
commit hash with your local `HEAD`.
- **Local extensions:** The CLI compares the `version` field in the source
directory's manifest with the installed version.
To verify an extension's installation type, inspect the `type` field in the
metadata file at `~/.gemini/extensions/<name>/.gemini-extension-install.json`.
</details>
<!-- prettier-ignore -->
> [!IMPORTANT]
> The `migratedTo` flow requires at least one release on the new repository for
> the CLI to recognize it as a valid update source.
+14 -1
View File
@@ -700,6 +700,19 @@ their corresponding top-level category object in your `settings.json` file.
"extends": "gemini-3-flash-base",
"modelConfig": {}
},
"context-snapshotter": {
"extends": "gemini-3-flash-base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"thinkingLevel": "HIGH"
},
"temperature": 1,
"topP": 0.95,
"topK": 64
}
}
},
"chat-compression-3-pro": {
"modelConfig": {
"model": "gemini-3-pro-preview"
@@ -1795,7 +1808,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.voice.stopGracePeriodMs`** (number):
- **Description:** How long to wait for final transcription after stopping
recording.
- **Default:** `1000`
- **Default:** `4000`
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
- **Description:** Enable non-interactive agent sessions.
+23
View File
@@ -326,6 +326,29 @@ lines and `3w` moves forward three words.
Counts are also supported for editing commands. For example, `3dd` deletes three
lines and `2cw` changes two words.
### Find, replace, yank, and paste in NORMAL mode
| Action | Keys |
| ----------------------------------------- | ----------- |
| Find next matching character | `f{char}` |
| Find previous matching character | `F{char}` |
| Move until before next matching character | `t{char}` |
| Move until after previous matching char | `T{char}` |
| Repeat latest character find | `;` |
| Repeat latest character find in reverse | `,` |
| Delete character before cursor | `X` |
| Toggle case under cursor | `~` |
| Replace character under cursor | `r{char}` |
| Yank line | `yy` |
| Yank to end of line | `Y` or `y$` |
| Yank word / WORD | `yw`, `yW` |
| Yank to end of word / WORD | `ye`, `yE` |
| Paste after cursor | `p` |
| Paste before cursor | `P` |
Delete and change operators also compose with character-find motions, so
commands such as `dfx`, `dtx`, `cFx`, and `cTx` are supported.
## Limitations
- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal):
+94
View File
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { LlmRole, type BaseLlmClient } from '@google/gemini-cli-core';
export interface JudgeOptions {
/**
* The number of parallel generations to run for majority voting.
* Defaults to 1. Use 3 or 5 for self-consistency.
*/
selfConsistencyRuns?: number;
/**
* The model to use for judging. Defaults to gemini-3-flash-base.
*/
model?: string;
}
export interface JudgeResult {
verdict: boolean;
reasoning: string[];
votes: { yes: number; no: number; other: number };
}
/**
* A reusable LLM-as-a-judge utility for behavioral evaluations.
*/
export class LLMJudge {
constructor(private readonly llmClient: BaseLlmClient) {}
/**
* Asks the LLM a Yes/No question and returns a boolean verdict.
* If selfConsistencyRuns > 1, it runs in parallel and returns the majority vote.
*/
async judgeYesNo(
question: string,
options: JudgeOptions = {},
): Promise<JudgeResult> {
const runs = options.selfConsistencyRuns ?? 1;
const model = options.model ?? 'gemini-3-flash-base';
const systemPrompt = `You are a strict, impartial expert judge. Read the provided evidence and question carefully. You MUST answer the question with ONLY "YES" or "NO". Do not provide any conversational filler or explanation before your answer.`;
const generateCall = async (): Promise<string> => {
try {
const response = await this.llmClient.generateContent({
modelConfigKey: { model },
contents: [{ role: 'user', parts: [{ text: question }] }],
systemInstruction: {
role: 'system',
parts: [{ text: systemPrompt }],
},
promptId: 'llm-judge-eval',
role: LlmRole.UTILITY_TOOL,
abortSignal: new AbortController().signal,
});
const text =
response.candidates?.[0]?.content?.parts?.[0]?.text
?.trim()
?.toUpperCase() || 'ERROR';
return text;
} catch (e: any) {
return `ERROR: ${e.message}`;
}
};
const promises = Array.from({ length: runs }).map(() => generateCall());
const rawResults = await Promise.all(promises);
let yes = 0;
let no = 0;
let other = 0;
for (const res of rawResults) {
// Remove any punctuation the model might have appended
const cleanRes = res.replace(/[^A-Z]/g, '');
if (cleanRes.startsWith('YES')) yes++;
else if (cleanRes.startsWith('NO')) no++;
else other++;
}
// Pass if YES > NO and YES > OTHER (plurality)
const pass = yes > no && yes > other;
return {
verdict: pass,
reasoning: rawResults,
votes: { yes, no, other },
};
}
}
+147
View File
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import {
componentEvalTest,
type ComponentEvalCase,
} from './component-test-helper.js';
import { type EvalPolicy } from './test-helper.js';
import { SnapshotGenerator } from '@google/gemini-cli-core';
import { NodeType, type ConcreteNode } from '@google/gemini-cli-core';
import { LLMJudge } from './llm-judge.js';
function snapshotEvalTest(policy: EvalPolicy, evalCase: ComponentEvalCase) {
return componentEvalTest(policy, evalCase);
}
describe('snapshot_fidelity', () => {
snapshotEvalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'SnapshotGenerator strictly retains specific empirical facts',
assert: async (config) => {
// 1. Construct a highly specific mock transcript containing 3 empirical facts we can test for:
// Fact A: File path -> src/compiler/server.ts
// Fact B: Error code -> COMPILE_ERR_404
// Fact C: Active Directive -> "do not fix it just yet"
const mockNodes: ConcreteNode[] = [
{
id: '1',
turnId: '1',
type: NodeType.USER_PROMPT,
timestamp: Date.now(),
role: 'user',
payload: {
text: 'I am trying to debug a weird timeout issue when compiling the TS server.',
},
},
{
id: '2',
turnId: '2',
type: NodeType.TOOL_EXECUTION,
timestamp: Date.now() + 100,
role: 'model',
payload: {
functionCall: {
name: 'run_shell_command',
args: { cmd: 'grep -rn "timeout" src/' },
},
},
},
{
id: '3',
turnId: '2',
type: NodeType.TOOL_EXECUTION,
timestamp: Date.now() + 200,
role: 'user',
payload: {
functionResponse: {
name: 'run_shell_command',
response: {
output:
'src/compiler/server.ts:442: setTimeout(() => reject(new Error("COMPILE_ERR_404")), 5000);',
},
},
},
},
{
id: '4',
turnId: '3',
type: NodeType.AGENT_YIELD,
timestamp: Date.now() + 300,
role: 'model',
payload: {
text: 'I found the exact line. It looks like the compiler throws COMPILE_ERR_404 if it hits 5 seconds.',
},
},
{
id: '5',
turnId: '4',
type: NodeType.USER_PROMPT,
timestamp: Date.now() + 400,
role: 'user',
payload: {
text: 'Okay, do not fix it just yet. I want you to remember this error code (COMPILE_ERR_404) and file path. First, list all the files in the directory.',
},
},
];
// 2. Extract the LLM Client from the component container
const llmClient = config.getBaseLlmClient();
const generator = new SnapshotGenerator({
llmClient,
promptId: 'eval-snapshot-test',
tokenCalculator: {
estimateTokensForString(str: string): number {
return str.length * 4;
},
},
} as any);
// 3. Generate the snapshot using the CURRENT system prompt
const snapshotText = await generator.synthesizeSnapshot(mockNodes);
// 4. Use LLM-as-a-Judge with Self-Consistency to evaluate factual fidelity
const judge = new LLMJudge(llmClient);
const judgePrompt = `
EVIDENCE (CONTEXT SNAPSHOT):
"""
${snapshotText}
"""
QUESTION:
Does the EVIDENCE explicitly contain all three of the following facts:
1. The specific file path "src/compiler/server.ts"
2. The specific error code "COMPILE_ERR_404"
3. The user's active constraint/directive to "do not fix it just yet" (or equivalent warning that implementation is paused)
Answer ONLY with "YES" if all three are unambiguously present.
Answer "NO" if any of the three are missing, abstracted away, or generalized (e.g., if it says "found an error" instead of "COMPILE_ERR_404").`;
// Use a self-consistency of 3 runs to get a robust majority vote
const result = await judge.judgeYesNo(judgePrompt, {
selfConsistencyRuns: 3,
});
// 5. Assert the verdict
const formattedVotes = JSON.stringify(result.votes);
const formattedReasoning = JSON.stringify(result.reasoning);
expect(
result.verdict,
`Snapshot failed to retain empirical facts.
Votes: ${formattedVotes}
Reasoning: ${formattedReasoning}
Generated Snapshot:
${snapshotText}`,
).toBe(true);
},
});
});
+2 -1
View File
@@ -231,7 +231,8 @@ describe('Plan Mode', () => {
`Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
).toBe(true);
});
it('should switch from a pro model to a flash model after exiting plan mode', async () => {
it.skip('should switch from a pro model to a flash model after exiting plan mode', async () => {
const plansDir = 'plans-folder';
const planFilename = 'my-plan.md';
+32 -3
View File
@@ -449,7 +449,8 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1535,6 +1536,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -2212,6 +2214,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2392,6 +2395,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2441,6 +2445,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2815,6 +2820,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2848,6 +2854,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2902,6 +2909,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4139,6 +4147,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4412,6 +4421,7 @@
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.58.2",
"@typescript-eslint/types": "8.58.2",
@@ -5187,6 +5197,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7304,7 +7315,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7889,6 +7901,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8499,6 +8512,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9765,6 +9779,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10024,6 +10039,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
@@ -13799,6 +13815,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13809,6 +13826,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15961,6 +15979,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16183,7 +16202,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16191,6 +16211,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16356,6 +16377,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16423,6 +16445,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -16842,6 +16865,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -17412,6 +17436,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17424,6 +17449,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -18062,6 +18088,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -18498,6 +18525,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -18616,6 +18644,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+24 -2
View File
@@ -234,7 +234,7 @@ describe('parseArguments', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should fail if both --resume and --session-id are provided', async () => {
it('should fail if multiple session flags are provided', async () => {
process.argv = [
'node',
'script.js',
@@ -255,7 +255,7 @@ describe('parseArguments', () => {
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --resume (-r) and --session-id together',
'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.',
),
);
});
@@ -1043,6 +1043,28 @@ describe('loadCliConfig', () => {
expect(config.isInteractive()).toBe(false);
});
describe('isAcpMode', () => {
it('should force skipNextSpeakerCheck to true when in ACP mode', async () => {
process.argv = ['node', 'script.js', '--acp'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
model: { skipNextSpeakerCheck: false },
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSkipNextSpeakerCheck()).toBe(true);
});
it('should respect settings.model.skipNextSpeakerCheck when not in ACP mode', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
model: { skipNextSpeakerCheck: false },
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSkipNextSpeakerCheck()).toBe(false);
});
});
});
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
+19 -3
View File
@@ -97,6 +97,7 @@ export interface CliArgs {
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
sessionFile?: string | undefined;
sessionId: string | undefined;
listSessions: boolean | undefined;
deleteSession: string | undefined;
@@ -239,8 +240,14 @@ export async function parseArguments(
? query.length > 0
: !!query;
if (argv['resume'] !== undefined && argv['session-id'] !== undefined) {
return 'Cannot use both --resume (-r) and --session-id together';
const sessionFlags = [
argv['resume'] !== undefined,
argv['session-id'] !== undefined,
argv['session-file'] !== undefined,
].filter(Boolean).length;
if (sessionFlags > 1) {
return 'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.';
}
if (argv['prompt'] && hasPositionalQuery) {
@@ -412,6 +419,11 @@ export async function parseArguments(
return trimmed;
},
})
.option('session-file', {
type: 'string',
nargs: 1,
description: 'Load a session from a JSON file',
})
.option('session-id', {
type: 'string',
nargs: 1,
@@ -1093,7 +1105,11 @@ export async function loadCliConfig(
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
// In ACP mode, always skip the next-speaker check. This check triggers
// recursive continuation turns inside GeminiClient.processTurn() that
// conflict with ACP's explicit turn management via session/prompt,
// causing infinite agent_thought_chunk loops.
skipNextSpeakerCheck: isAcpMode || settings.model?.skipNextSpeakerCheck,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
eventEmitter: coreEvents,
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
+2 -1
View File
@@ -1300,7 +1300,8 @@ export async function inferInstallMetadata(
source.startsWith('git@') ||
source.startsWith('sso://') ||
source.startsWith('github:') ||
source.startsWith('gitlab:')
source.startsWith('gitlab:') ||
source.startsWith('ssh://')
) {
return {
source,
@@ -120,6 +120,7 @@ describe('footerItems', () => {
'quota',
'memory-usage',
'session-id',
'hostname',
'code-changes',
'token-count',
]);
+6
View File
@@ -47,6 +47,11 @@ export const ALL_ITEMS = [
header: 'session',
description: 'Unique identifier for the current session',
},
{
id: 'hostname',
header: 'machine',
description: 'Current machine hostname',
},
{
id: 'auth',
header: '/auth',
@@ -75,6 +80,7 @@ export const DEFAULT_ORDER = [
'quota',
'memory-usage',
'session-id',
'hostname',
'auth',
'code-changes',
'token-count',
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { parseArguments } from './config.js';
import { createTestMergedSettings } from './settings.js';
describe('parseArguments mutual exclusivity', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const combinations = [
['--resume', '--session-id', 'test-id'],
['--resume', '--session-file', 'test.json'],
['--session-id', 'test-id', '--session-file', 'test.json'],
['--resume', '--session-id', 'test-id', '--session-file', 'test.json'],
];
combinations.forEach((args) => {
it(`should fail if ${args.filter((a) => a.startsWith('--')).join(' and ')} are provided`, async () => {
process.argv = ['node', 'script.js', ...args];
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
await expect(parseArguments(createTestMergedSettings())).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.',
),
);
});
});
});
+48 -1
View File
@@ -2149,13 +2149,60 @@ const SETTINGS_SCHEMA = {
label: 'Voice Stop Grace Period (ms)',
category: 'Experimental',
requiresRestart: false,
default: 1000,
default: 4000,
description:
'How long to wait for final transcription after stopping recording.',
showInDialog: true,
},
},
},
contextCaching: {
type: 'object',
label: 'Context Caching',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Explicit context caching for the main agent.',
showInDialog: true,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Context Caching',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable explicit context caching for the main agent.',
showInDialog: true,
},
thresholdTokens: {
type: 'number',
label: 'Threshold Tokens',
category: 'Experimental',
requiresRestart: true,
default: 32768,
description: 'Minimum tokens required to trigger explicit caching.',
showInDialog: true,
},
ttlMinutes: {
type: 'number',
label: 'TTL (Minutes)',
category: 'Experimental',
requiresRestart: true,
default: 60,
description: 'Time to live for a cache resource in minutes.',
showInDialog: true,
},
autoRenew: {
type: 'boolean',
label: 'Auto Renew',
category: 'Experimental',
requiresRestart: true,
default: true,
description: 'Automatically extend TTL on use.',
showInDialog: true,
},
},
},
adk: {
type: 'object',
label: 'ADK',
+104 -28
View File
@@ -44,6 +44,7 @@ import {
type Config,
type ResumedSessionData,
type StartupWarning,
type ConversationRecord,
WarningPriority,
debugLogger,
coreEvents,
@@ -828,14 +829,14 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it('should handle session selector error', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
resolveSession: vi
.fn()
.mockRejectedValue(new Error('Session not found')),
}) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
);
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
resolveSession: vi
.fn()
.mockRejectedValue(new Error('Session not found')),
} as unknown as InstanceType<typeof SessionSelector>;
});
const processExitSpy = vi
.spyOn(process, 'exit')
@@ -884,14 +885,14 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it('should start normally with a warning when no sessions found for resume', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
resolveSession: vi
.fn()
.mockRejectedValue(SessionError.noSessionsFound()),
}) as unknown as InstanceType<typeof SessionSelector>,
);
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
resolveSession: vi
.fn()
.mockRejectedValue(SessionError.noSessionsFound()),
} as unknown as InstanceType<typeof SessionSelector>;
});
const processExitSpy = vi
.spyOn(process, 'exit')
@@ -1068,13 +1069,88 @@ describe('resolveSessionId', () => {
expect(resumedSessionData).toBeUndefined();
});
it('should import from session file when sessionFile is provided', async () => {
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
sessionExists: vi.fn().mockResolvedValue(false),
} as unknown as InstanceType<typeof SessionSelector>;
});
const coreModule = await import('@google/gemini-cli-core');
vi.spyOn(coreModule, 'loadConversationRecord').mockResolvedValueOnce({
sessionId: 'old-session-id',
projectHash: 'hash',
startTime: 'time',
lastUpdated: 'time',
messages: [
{ type: 'info', content: 'Old info', id: '1' },
{ type: 'user', content: 'Hello', id: '2' },
{ type: 'gemini', content: 'Hi', id: '3' },
{ type: 'error', content: 'Old error', id: '4' },
{ type: 'user', id: '5' }, // Missing content
null, // Null object
{ type: 'unknown', content: 'Something', id: '6' }, // Unknown type
],
} as unknown as ConversationRecord);
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
try {
const { sessionId, resumedSessionData } = await resolveSessionId(
undefined,
undefined,
'dummy-session.json',
);
expect(sessionId).toBeDefined();
expect(sessionId).not.toBe('old-session-id'); // A new session ID should be created
expect(resumedSessionData).toBeDefined();
expect(resumedSessionData?.conversation.sessionId).toBe(sessionId); // Overwritten
// Verify messages: should have 1 info (the new import confirmation) + 2 valid conversation messages
// Invalid messages (missing content, null, unknown type) and transient messages should be filtered out.
expect(resumedSessionData?.conversation.messages).toHaveLength(3);
expect(resumedSessionData?.conversation.messages![0]).toMatchObject({
type: 'info',
content: expect.stringContaining('Imported session from'),
});
expect(resumedSessionData?.conversation.messages![1]).toMatchObject({
type: 'user',
content: 'Hello',
});
expect(resumedSessionData?.conversation.messages![2]).toMatchObject({
type: 'gemini',
content: 'Hi',
});
expect(resumedSessionData?.filePath).toContain(sessionId.slice(0, 8)); // New path
} catch (e) {
if (e instanceof MockProcessExitError) {
throw new Error(
'process.exit called with: ' +
JSON.stringify(emitFeedbackSpy.mock.calls),
);
}
throw e;
} finally {
emitFeedbackSpy.mockRestore();
processExitSpy.mockRestore();
}
});
it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
sessionExists: vi.fn().mockResolvedValue(true),
}) as unknown as InstanceType<typeof SessionSelector>,
);
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
sessionExists: vi.fn().mockResolvedValue(true),
} as unknown as InstanceType<typeof SessionSelector>;
});
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
const processExitSpy = vi
@@ -1100,12 +1176,12 @@ describe('resolveSessionId', () => {
});
it('should return provided sessionId when it does not exist', async () => {
vi.mocked(SessionSelector).mockImplementation(
() =>
({
sessionExists: vi.fn().mockResolvedValue(false),
}) as unknown as InstanceType<typeof SessionSelector>,
);
// eslint-disable-next-line prefer-arrow-callback
vi.mocked(SessionSelector).mockImplementation(function () {
return {
sessionExists: vi.fn().mockResolvedValue(false),
} as unknown as InstanceType<typeof SessionSelector>;
});
const { sessionId, resumedSessionData } = await resolveSessionId(
undefined,
'new-id',
+82 -1
View File
@@ -35,6 +35,9 @@ import {
debugLogger,
isHeadlessMode,
Storage,
getProjectHash,
loadConversationRecord,
type MessageRecord,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -44,6 +47,8 @@ import { createHash } from 'node:crypto';
import v8 from 'node:v8';
import os from 'node:os';
import dns from 'node:dns';
import * as path from 'node:path';
import * as fsPromises from 'node:fs/promises';
import { start_sandbox } from './utils/sandbox.js';
import {
loadSettings,
@@ -194,11 +199,12 @@ ${reason.stack}`
export async function resolveSessionId(
resumeArg: string | undefined,
sessionIdArg?: string | undefined,
sessionFileArg?: string | undefined,
): Promise<{
sessionId: string;
resumedSessionData?: ResumedSessionData;
}> {
if (!resumeArg && !sessionIdArg) {
if (!resumeArg && !sessionIdArg && !sessionFileArg) {
return { sessionId: createSessionId() };
}
@@ -207,6 +213,80 @@ export async function resolveSessionId(
const sessionSelector = new SessionSelector(storage);
if (sessionFileArg) {
try {
const sessionData = await loadConversationRecord(sessionFileArg);
if (!sessionData) {
throw new Error(`File not found or invalid format: ${sessionFileArg}`);
}
const now = Date.now();
const isoNow = new Date(now).toISOString();
// Filter out old system/info messages that are specific to the previous run
// and only keep actual conversation messages (user/gemini).
// Best effort parse: ensure message is an object and has required fields.
sessionData.messages = (sessionData.messages || []).filter(
(m) =>
typeof m === 'object' &&
m !== null &&
(m.type === 'user' || m.type === 'gemini') &&
m.content !== undefined,
);
// Add a single info message to the history to confirm the import
sessionData.messages.unshift({
id: `import-${now}`,
type: 'info',
content: `Imported session from ${sessionFileArg}`,
timestamp: isoNow,
} as MessageRecord);
const newSessionId = createSessionId();
sessionData.sessionId = newSessionId;
sessionData.projectHash = getProjectHash(storage.getProjectRoot());
sessionData.startTime = isoNow;
sessionData.lastUpdated = isoNow;
const chatsDir = path.join(storage.getProjectTempDir(), 'chats');
const newSessionPath = path.join(
chatsDir,
`session-${now}-${newSessionId.slice(0, 8)}.jsonl`,
);
const { messages: _messages, ...initialMetadata } = sessionData;
const lines = [JSON.stringify(initialMetadata)];
if (sessionData.messages) {
for (const msg of sessionData.messages) {
lines.push(JSON.stringify(msg));
}
}
await fsPromises.mkdir(chatsDir, { recursive: true });
await fsPromises.writeFile(
newSessionPath,
lines.join('\n') + '\n',
'utf-8',
);
return {
sessionId: newSessionId,
resumedSessionData: {
conversation: sessionData,
filePath: newSessionPath,
},
};
} catch (error) {
coreEvents.emitFeedback(
'error',
`Error importing session from file: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
await runExitCleanup();
process.exit(ExitCodes.FATAL_INPUT_ERROR);
}
}
if (sessionIdArg) {
if (await sessionSelector.sessionExists(sessionIdArg)) {
coreEvents.emitFeedback(
@@ -340,6 +420,7 @@ export async function main() {
const { sessionId, resumedSessionData } = await resolveSessionId(
argv.resume,
argv.sessionId,
argv.sessionFile,
);
if (
@@ -100,7 +100,9 @@ vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
vi.mock('../ui/commands/shortcutsCommand.js', () => ({
shortcutsCommand: {},
}));
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
vi.mock('../ui/commands/memoryCommand.js', () => ({
memoryCommand: () => ({}),
}));
vi.mock('../ui/commands/modelCommand.js', () => ({
modelCommand: { name: 'model' },
}));
@@ -30,6 +30,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { exportSessionCommand } from '../ui/commands/exportSessionCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
@@ -135,6 +136,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
copyCommand,
corgiCommand,
docsCommand,
exportSessionCommand,
directoryCommand,
editorCommand,
...(this.config?.getExtensionsEnabled() === false
@@ -185,7 +187,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
},
]
: [mcpCommand]),
memoryCommand,
memoryCommand(this.config),
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
...(this.config?.isPlanEnabled() ? [planCommand] : []),
@@ -6,7 +6,12 @@
import * as glob from 'glob';
import * as path from 'node:path';
import { GEMINI_DIR, Storage, type Config } from '@google/gemini-cli-core';
import {
GEMINI_DIR,
Storage,
type Config,
homedir,
} from '@google/gemini-cli-core';
import mock from 'mock-fs';
import { FileCommandLoader } from './FileCommandLoader.js';
import { assert, vi } from 'vitest';
@@ -21,7 +26,7 @@ import {
ShellProcessor,
} from './prompt-processors/shellProcessor.js';
import { DefaultArgumentProcessor } from './prompt-processors/argumentProcessor.js';
import type { CommandContext } from '../ui/commands/types.js';
import { CommandKind, type CommandContext } from '../ui/commands/types.js';
import { AtFileProcessor } from './prompt-processors/atFileProcessor.js';
const mockShellProcess = vi.hoisted(() => vi.fn());
@@ -315,6 +320,34 @@ describe('FileCommandLoader', () => {
}
});
it('does not duplicate commands when project root is the home directory', async () => {
const homeDir = homedir();
const userCommandsDir = Storage.getUserCommandsDir();
mock({
[userCommandsDir]: {
'test.toml': 'prompt = "User prompt"',
'another.toml': 'prompt = "Another prompt"',
},
});
const mockConfig = {
getProjectRoot: vi.fn(() => homeDir),
getExtensions: vi.fn(() => []),
getFolderTrust: vi.fn(() => false),
isTrustedFolder: vi.fn(() => false),
} as unknown as Config;
const loader = new FileCommandLoader(mockConfig);
const commands = await loader.loadCommands(signal);
// Should load each command only once (as user commands), not twice
expect(commands).toHaveLength(2);
const names = commands.map((c) => c.name);
expect(names).toContain('test');
expect(names).toContain('another');
// Verify they are loaded as user commands, not duplicated as workspace commands
expect(commands.every((c) => c.kind === CommandKind.USER_FILE)).toBe(true);
});
it('ignores files with TOML syntax errors', async () => {
const userCommandsDir = Storage.getUserCommandsDir();
mock({
+10 -6
View File
@@ -212,16 +212,20 @@ export class FileCommandLoader implements ICommandLoader {
const storage = this.config?.storage ?? new Storage(this.projectRoot);
// 1. User commands
const userCommandsDir = Storage.getUserCommandsDir();
dirs.push({
path: Storage.getUserCommandsDir(),
path: userCommandsDir,
kind: CommandKind.USER_FILE,
});
// 2. Project commands
dirs.push({
path: storage.getProjectCommandsDir(),
kind: CommandKind.WORKSPACE_FILE,
});
// 2. Project commands (skip if same directory as user commands, e.g. when
// cwd is the user's home directory, to avoid false conflict warnings)
if (!storage.isWorkspaceHomeDir()) {
dirs.push({
path: storage.getProjectCommandsDir(),
kind: CommandKind.WORKSPACE_FILE,
});
}
// 3. Extension commands (processed last to detect all conflicts)
if (this.config) {
+36
View File
@@ -1267,6 +1267,42 @@ describe('AppContainer State Management', () => {
});
});
describe('SessionStart Hook Rendering', () => {
it('does not render systemMessage directly (avoids duplicate with HookSystemMessage event)', async () => {
const mockAddItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem: mockAddItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
});
const fireSessionStartEvent = vi.fn().mockResolvedValue({
systemMessage: 'Hello from SessionStart hook',
getAdditionalContext: vi.fn(() => undefined),
});
vi.spyOn(mockConfig, 'getHookSystem').mockReturnValue({
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent,
} as unknown as ReturnType<Config['getHookSystem']>);
const { unmount } = await act(async () => renderAppContainer());
await waitFor(() => expect(fireSessionStartEvent).toHaveBeenCalled());
// The direct-render path (the bug) would call addItem with the
// systemMessage text and no `source` field. The HookSystemMessage
// event-listener path (the correct one) always sets `source`.
const directRenderCall = mockAddItem.mock.calls.find(
([item]) =>
item?.text === 'Hello from SessionStart hook' && !item?.source,
);
expect(directRenderCall).toBeUndefined();
unmount();
});
});
describe('Token Counting from Session Stats', () => {
it('tracks token counts from session messages', async () => {
// Session stats are provided through the SessionStatsProvider context
-16
View File
@@ -497,16 +497,6 @@ export const AppContainer = (props: AppContainerProps) => {
?.fireSessionStartEvent(sessionStartSource);
if (result) {
if (result.systemMessage) {
historyManager.addItem(
{
type: MessageType.INFO,
text: result.systemMessage,
},
Date.now(),
);
}
const additionalContext = result.getAdditionalContext();
const geminiClient = config.getGeminiClient();
if (additionalContext && geminiClient) {
@@ -549,12 +539,6 @@ export const AppContainer = (props: AppContainerProps) => {
debugLogger.error('Error during cleanup:', e),
);
};
// Disable the dependencies check here. historyManager gets flagged
// but we don't want to react to changes to it because each new history
// item, including the ones from the start session hook will cause a
// re-render and an error when we try to reload config.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, resumedSessionData]);
useEffect(
@@ -9,7 +9,11 @@ import open from 'open';
import path from 'node:path';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getVersion, type Config } from '@google/gemini-cli-core';
import {
getVersion,
type Config,
type ConversationRecord,
} from '@google/gemini-cli-core';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatBytes } from '../utils/formatters.js';
import { MessageType } from '../types.js';
@@ -157,6 +161,8 @@ describe('bugCommand', () => {
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'hi' }] },
];
const mockGetSubagentTrajectories = vi.fn().mockResolvedValue({});
const mockGetConversation = vi.fn().mockReturnValue({ messages: [] });
const mockContext = createMockCommandContext({
services: {
agentContext: {
@@ -173,6 +179,8 @@ describe('bugCommand', () => {
geminiClient: {
getChat: () => ({
getHistory: () => history,
getSubagentTrajectories: mockGetSubagentTrajectories,
getConversation: mockGetConversation,
}),
},
},
@@ -188,7 +196,9 @@ describe('bugCommand', () => {
);
expect(exportHistoryToFile).toHaveBeenCalledWith({
history,
messages: [],
filePath: expectedPath,
trajectories: {},
});
const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0];
@@ -203,6 +213,60 @@ describe('bugCommand', () => {
expect(messageText).toContain(encodeURIComponent(reminder));
});
it('should include subagent trajectories in history export if available', async () => {
const history = [
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'hi' }] },
];
const trajectories = {
'subagent-1': {
sessionId: 'subagent-1',
messages: [],
} as unknown as ConversationRecord,
};
const mockGetSubagentTrajectories = vi.fn().mockResolvedValue(trajectories);
const mockGetConversation = vi.fn().mockReturnValue({ messages: [] });
const mockContext = createMockCommandContext({
services: {
agentContext: {
config: {
getModel: () => 'gemini-pro',
getBugCommand: () => undefined,
getIdeMode: () => true,
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
storage: {
getProjectTempDir: () => '/tmp/gemini',
},
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
geminiClient: {
getChat: () => ({
getHistory: () => history,
getSubagentTrajectories: mockGetSubagentTrajectories,
getConversation: mockGetConversation,
}),
},
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'Bug with trajectories');
const expectedPath = path.join(
'/tmp/gemini',
'bug-report-history-1704067200000.json',
);
expect(mockGetSubagentTrajectories).toHaveBeenCalled();
expect(exportHistoryToFile).toHaveBeenCalledWith({
history,
messages: [],
filePath: expectedPath,
trajectories,
});
});
it('should use a custom URL template from config if provided', async () => {
const customTemplate =
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
+8 -1
View File
@@ -88,7 +88,14 @@ export const bugCommand: SlashCommand = {
const historyFileName = `bug-report-history-${Date.now()}.json`;
const historyFilePath = path.join(tempDir, historyFileName);
try {
await exportHistoryToFile({ history, filePath: historyFilePath });
const trajectories = await chat?.getSubagentTrajectories();
const messages = chat?.getConversation()?.messages;
await exportHistoryToFile({
history,
messages,
filePath: historyFilePath,
trajectories,
});
historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\n📄 **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`;
problemValue += `\n\n[ACTION REQUIRED] 📎 PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`;
} catch (err) {
@@ -63,6 +63,8 @@ describe('chatCommand', () => {
mockGetHistory = vi.fn().mockReturnValue([]);
mockGetChat = vi.fn().mockReturnValue({
getHistory: mockGetHistory,
getSubagentTrajectories: vi.fn().mockResolvedValue({}),
getConversation: vi.fn().mockReturnValue({ messages: [] }),
});
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] });
@@ -230,7 +232,12 @@ describe('chatCommand', () => {
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(
{ history, authType: AuthType.LOGIN_WITH_GOOGLE },
{
history,
authType: AuthType.LOGIN_WITH_GOOGLE,
trajectories: {},
messages: [],
},
tag,
);
expect(result).toEqual({
@@ -464,7 +471,9 @@ describe('chatCommand', () => {
);
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
});
expect(result).toEqual({
type: 'message',
@@ -479,7 +488,9 @@ describe('chatCommand', () => {
const expectedPath = path.join(process.cwd(), 'my-chat.json');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
});
expect(result).toEqual({
type: 'message',
@@ -494,7 +505,9 @@ describe('chatCommand', () => {
const expectedPath = path.join(process.cwd(), 'my-chat.md');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
});
expect(result).toEqual({
type: 'message',
@@ -544,7 +557,9 @@ describe('chatCommand', () => {
const expectedPath = path.join(process.cwd(), 'my-chat.json');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
});
});
@@ -554,7 +569,9 @@ describe('chatCommand', () => {
const expectedPath = path.join(process.cwd(), 'my-chat.md');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
});
});
});
+9 -2
View File
@@ -139,7 +139,12 @@ const saveCommand: SlashCommand = {
const history = chat.getHistory();
if (history.length > INITIAL_HISTORY_LENGTH) {
const authType = config?.getContentGeneratorConfig()?.authType;
await logger.saveCheckpoint({ history, authType }, tag);
const trajectories = await chat.getSubagentTrajectories();
const messages = chat.getConversation()?.messages;
await logger.saveCheckpoint(
{ history, authType, trajectories, messages },
tag,
);
return {
type: 'message',
messageType: 'info',
@@ -324,7 +329,9 @@ const shareCommand: SlashCommand = {
}
try {
await exportHistoryToFile({ history, filePath });
const trajectories = await chat.getSubagentTrajectories();
const messages = chat.getConversation()?.messages;
await exportHistoryToFile({ history, filePath, trajectories, messages });
return {
type: 'message',
messageType: 'info',
@@ -0,0 +1,135 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { exportSessionCommand } from './exportSessionCommand.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { SessionSelector } from '../../utils/sessionUtils.js';
import type { CommandContext } from './types.js';
import { Storage, type ConversationRecord } from '@google/gemini-cli-core';
vi.mock('node:fs/promises');
vi.mock('../../utils/sessionUtils.js');
describe('exportSessionCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(Storage.prototype, 'initialize').mockResolvedValue(undefined);
vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
path.join(path.sep, 'tmp', 'mock-dir'),
);
mockContext = {
services: {
agentContext: {
config: {
sessionId: 'test-session-id',
getSessionId: () => 'test-session-id',
storage: new Storage(process.cwd()),
},
},
},
invocation: {
args: ' export.json ',
name: 'export-session',
raw: '/export-session export.json',
},
ui: {
addItem: vi.fn(),
setPendingItem: vi.fn(),
pendingItem: null,
},
} as unknown as CommandContext;
});
it('should return error if no path is provided', async () => {
mockContext.invocation!.args = ' ';
const result = await exportSessionCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: expect.stringContaining('Please provide a file path'),
});
});
it('should return error if sessionId is missing', async () => {
mockContext.services.agentContext!.config.getSessionId = () =>
undefined as unknown as string;
const result = await exportSessionCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No active session found to export.',
});
});
it('should export the session successfully', async () => {
const mockSessionData: ConversationRecord = {
sessionId: 'test-session-id',
messages: [],
projectHash: 'hash',
startTime: 'time',
lastUpdated: 'time',
};
vi.mocked(SessionSelector.prototype.resolveSession).mockResolvedValue({
sessionData: mockSessionData,
sessionPath: path.join(
path.sep,
'tmp',
'mock-dir',
'chats',
'session.jsonl',
),
displayInfo: 'test',
});
vi.mocked(fs.access).mockRejectedValue(new Error('Not found'));
const result = await exportSessionCommand.action!(mockContext, '');
expect(result).toBeUndefined();
expect(fs.writeFile).toHaveBeenCalledWith(
path.resolve(process.cwd(), 'export.json'),
JSON.stringify(mockSessionData, null, 2),
'utf-8',
);
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'export_session',
exportSession: { isPending: true },
}),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'export_session',
exportSession: {
isPending: false,
targetPath: expect.stringContaining('export.json'),
},
}),
expect.any(Number),
);
expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null);
});
it('should return error if resolveSession fails', async () => {
vi.mocked(SessionSelector.prototype.resolveSession).mockRejectedValue(
new Error('Session not found'),
);
const result = await exportSessionCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Failed to export session: Session not found',
});
});
});
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
type CommandContext,
type SlashCommand,
type SlashCommandActionReturn,
CommandKind,
} from './types.js';
import { MessageType, type HistoryItemExportSession } from '../types.js';
import { SessionSelector } from '../../utils/sessionUtils.js';
export const exportSessionCommand: SlashCommand = {
name: 'export-session',
description: 'Export the current session to a JSON file',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn | void> => {
const { ui } = context;
const args = context.invocation?.args.trim();
if (!args) {
return {
type: 'message',
messageType: 'error',
content:
'Please provide a file path to export the session to. Example: /export-session ./my-session.json',
};
}
const sessionId = context.services.agentContext?.config.getSessionId();
if (!sessionId) {
return {
type: 'message',
messageType: 'error',
content: 'No active session found to export.',
};
}
if (ui.pendingItem) {
ui.addItem(
{
type: MessageType.ERROR,
text: 'Operation already in progress, please wait.',
},
Date.now(),
);
return;
}
const pendingMessage: HistoryItemExportSession = {
type: MessageType.EXPORT_SESSION,
exportSession: {
isPending: true,
},
};
try {
ui.setPendingItem(pendingMessage);
const storage = context.services.agentContext!.config.storage;
const sessionSelector = new SessionSelector(storage);
const { sessionData } = await sessionSelector.resolveSession(sessionId);
const targetPath = path.resolve(process.cwd(), args);
await fs.writeFile(
targetPath,
JSON.stringify(sessionData, null, 2),
'utf-8',
);
ui.addItem(
{
type: MessageType.EXPORT_SESSION,
exportSession: {
isPending: false,
targetPath,
},
} as HistoryItemExportSession,
Date.now(),
);
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to export session: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
} finally {
ui.setPendingItem(null);
}
},
};
@@ -11,6 +11,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import {
type Config,
refreshMemory,
refreshServerHierarchicalMemory,
SimpleExtensionLoader,
@@ -61,10 +62,17 @@ const mockRefreshServerHierarchicalMemory =
describe('memoryCommand', () => {
let mockContext: CommandContext;
const buildMemoryCommand = (isMemoryV2 = false): SlashCommand => {
const config: Pick<Config, 'isMemoryV2Enabled'> = {
isMemoryV2Enabled: () => isMemoryV2,
};
return memoryCommand(config as Config);
};
const getSubCommand = (
name: 'show' | 'add' | 'reload' | 'list',
): SlashCommand => {
const subCommand = memoryCommand.subCommands?.find(
const subCommand = buildMemoryCommand().subCommands?.find(
(cmd) => cmd.name === name,
);
if (!subCommand) {
@@ -73,6 +81,26 @@ describe('memoryCommand', () => {
return subCommand;
};
describe('Memory v2', () => {
it('omits the /memory add subcommand when memoryV2 is enabled', () => {
const command = buildMemoryCommand(true);
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).not.toContain('add');
});
it('includes the /memory add subcommand by default', () => {
const command = buildMemoryCommand(false);
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).toContain('add');
});
it('includes the /memory add subcommand when no config is provided', () => {
const command = memoryCommand(null);
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).toContain('add');
});
});
describe('/memory show', () => {
let showCommand: SlashCommand;
let mockGetUserMemory: Mock;
@@ -462,7 +490,7 @@ describe('memoryCommand', () => {
let inboxCommand: SlashCommand;
beforeEach(() => {
inboxCommand = memoryCommand.subCommands!.find(
inboxCommand = buildMemoryCommand().subCommands!.find(
(cmd) => cmd.name === 'inbox',
)!;
expect(inboxCommand).toBeDefined();
+165 -146
View File
@@ -7,6 +7,7 @@
import React from 'react';
import {
addMemory,
type Config,
listMemoryFiles,
refreshMemory,
showMemory,
@@ -20,155 +21,173 @@ import {
} from './types.js';
import { InboxDialog } from '../components/InboxDialog.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
description: 'Commands for interacting with memory',
const showSubCommand: SlashCommand = {
name: 'show',
description: 'Show the current memory contents',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const config = context.services.agentContext?.config;
if (!config) return;
const result = showMemory(config);
context.ui.addItem(
{
type: MessageType.INFO,
text: result.content,
},
Date.now(),
);
},
};
const addSubCommand: SlashCommand = {
name: 'add',
description: 'Add content to the memory',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [
{
name: 'show',
description: 'Show the current memory contents',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const config = context.services.agentContext?.config;
if (!config) return;
const result = showMemory(config);
action: (context, args): SlashCommandActionReturn | void => {
const result = addMemory(args);
context.ui.addItem(
{
type: MessageType.INFO,
text: result.content,
},
Date.now(),
);
if (result.type === 'message') {
return result;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${args.trim()}"`,
},
},
{
name: 'add',
description: 'Add content to the memory',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: (context, args): SlashCommandActionReturn | void => {
const result = addMemory(args);
Date.now(),
);
if (result.type === 'message') {
return result;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${args.trim()}"`,
},
Date.now(),
);
return result;
},
},
{
name: 'reload',
altNames: ['refresh'],
description: 'Reload the memory from the source',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Reloading memory from source files...',
},
Date.now(),
);
try {
const config = context.services.agentContext?.config;
if (config) {
const result = await refreshMemory(config);
context.ui.addItem(
{
type: MessageType.INFO,
text: result.content,
},
Date.now(),
);
}
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
text: `Error reloading memory: ${(error as Error).message}`,
},
Date.now(),
);
}
},
},
{
name: 'list',
description: 'Lists the paths of the GEMINI.md files in use',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const config = context.services.agentContext?.config;
if (!config) return;
const result = listMemoryFiles(config);
context.ui.addItem(
{
type: MessageType.INFO,
text: result.content,
},
Date.now(),
);
},
},
{
name: 'inbox',
description:
'Review skills extracted from past sessions and move them to global or project skills',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (
context,
): OpenCustomDialogActionReturn | SlashCommandActionReturn | void => {
const config = context.services.agentContext?.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
if (!config.isAutoMemoryEnabled()) {
return {
type: 'message',
messageType: 'info',
content:
'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.',
};
}
return {
type: 'custom_dialog',
component: React.createElement(InboxDialog, {
config,
onClose: () => context.ui.removeComponent(),
onReloadSkills: async () => {
await config.reloadSkills();
context.ui.reloadCommands();
},
onReloadMemory: async () => {
await refreshMemory(config);
},
}),
};
},
},
],
return result;
},
};
const reloadSubCommand: SlashCommand = {
name: 'reload',
altNames: ['refresh'],
description: 'Reload the memory from the source',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Reloading memory from source files...',
},
Date.now(),
);
try {
const config = context.services.agentContext?.config;
if (config) {
const result = await refreshMemory(config);
context.ui.addItem(
{
type: MessageType.INFO,
text: result.content,
},
Date.now(),
);
}
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
text: `Error reloading memory: ${(error as Error).message}`,
},
Date.now(),
);
}
},
};
const listSubCommand: SlashCommand = {
name: 'list',
description: 'Lists the paths of the GEMINI.md files in use',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const config = context.services.agentContext?.config;
if (!config) return;
const result = listMemoryFiles(config);
context.ui.addItem(
{
type: MessageType.INFO,
text: result.content,
},
Date.now(),
);
},
};
const inboxSubCommand: SlashCommand = {
name: 'inbox',
description:
'Review skills extracted from past sessions and move them to global or project skills',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (
context,
): OpenCustomDialogActionReturn | SlashCommandActionReturn | void => {
const config = context.services.agentContext?.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
if (!config.isAutoMemoryEnabled()) {
return {
type: 'message',
messageType: 'info',
content:
'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.',
};
}
return {
type: 'custom_dialog',
component: React.createElement(InboxDialog, {
config,
onClose: () => context.ui.removeComponent(),
onReloadSkills: async () => {
await config.reloadSkills();
context.ui.reloadCommands();
},
onReloadMemory: async () => {
await refreshMemory(config);
},
}),
};
},
};
export const memoryCommand = (config: Config | null): SlashCommand => {
// The `add` subcommand depends on the `save_memory` tool, which is not
// registered when Memory v2 is enabled. Omit it in that case.
const isMemoryV2 = config?.isMemoryV2Enabled() ?? false;
const subCommands: SlashCommand[] = [
showSubCommand,
...(isMemoryV2 ? [] : [addSubCommand]),
reloadSubCommand,
listSubCommand,
inboxSubCommand,
];
return {
name: 'memory',
description: 'Commands for interacting with memory',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands,
};
};
+12
View File
@@ -18,6 +18,7 @@ import {
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import os from 'node:os';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
@@ -35,6 +36,8 @@ import {
} from '../../config/footerItems.js';
import { isDevelopment } from '../../utils/installationInfo.js';
const HOSTNAME = os.hostname();
interface CwdIndicatorProps {
targetDir: string;
maxWidth: number;
@@ -386,6 +389,15 @@ export const Footer: React.FC = () => {
);
break;
}
case 'hostname': {
addCol(
id,
header,
() => <Text color={itemColor}>{HOSTNAME}</Text>,
HOSTNAME.length,
);
break;
}
case 'auth': {
if (!settings.merged.ui.showUserIdentity) break;
if (!authType) break;
@@ -116,7 +116,7 @@ describe('<FooterConfigDialog />', () => {
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'code-changes' (which has colored elements)
for (let i = 0; i < 9; i++) {
for (let i = 0; i < 10; i++) {
act(() => {
stdin.write('\u001b[B'); // Down arrow
});
@@ -249,6 +249,9 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
'session-id': (
<Text color={getColor('session-id', itemColor)}>769992f9</Text>
),
hostname: (
<Text color={getColor('hostname', itemColor)}>dev-machine</Text>
),
'code-changes': (
<Box flexDirection="row">
<Text color={getColor('code-changes', theme.status.success)}>
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { MessageType, type HistoryItem } from '../types.js';
@@ -198,6 +199,25 @@ describe('<HistoryItemDisplay />', () => {
unmount();
});
it('renders ExportSessionMessage for "export_session" type', async () => {
const testPath = path.join(path.sep, 'test', 'path.json');
const item: HistoryItem = {
...baseItem,
type: 'export_session',
exportSession: {
isPending: false,
targetPath: testPath,
},
};
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain(
`Successfully exported session to ${testPath}`,
);
unmount();
});
it('should escape ANSI codes in text content', async () => {
const historyItem: HistoryItem = {
id: 1,
@@ -17,6 +17,7 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { ToolGroupDisplay } from './messages/ToolGroupDisplay.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { ExportSessionMessage } from './messages/ExportSessionMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
import { SubagentHistoryMessage } from './messages/SubagentHistoryMessage.js';
import { Box } from 'ink';
@@ -211,6 +212,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'compression' && (
<CompressionMessage compression={itemForDisplay.compression} />
)}
{itemForDisplay.type === 'export_session' && (
<ExportSessionMessage exportSession={itemForDisplay.exportSession} />
)}
{itemForDisplay.type === 'extensions_list' && (
<ExtensionsList extensions={itemForDisplay.extensions} />
)}
@@ -13,6 +13,7 @@ import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
isWindows,
type WorktreeSettings,
} from '@google/gemini-cli-core';
@@ -22,6 +23,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
getShellConfiguration: vi.fn(),
isWindows: vi.fn(),
};
});
@@ -44,6 +46,7 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
});
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const isWindowsMock = vi.mocked(isWindows);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const renderWithMockedStats = async (
@@ -106,6 +109,7 @@ describe('<SessionSummaryDisplay />', () => {
argsPrefix: ['-c'],
shell: 'bash',
});
isWindowsMock.mockReturnValue(false);
});
it('renders the summary display with a title', async () => {
@@ -149,7 +153,7 @@ describe('<SessionSummaryDisplay />', () => {
);
const output = lastFrame();
// Standard UUID characters should not be escaped/quoted by default for bash.
// Standard UUID characters are NOT wrapped in double quotes on non-Windows.
expect(output).toContain('gemini --resume 1234-abcd-5678-efgh');
unmount();
});
@@ -167,7 +171,8 @@ describe('<SessionSummaryDisplay />', () => {
unmount();
});
it('renders a standard UUID-formatted session ID in the footer (powershell)', async () => {
it('renders a standard UUID-formatted session ID in the footer (powershell) on Windows', async () => {
isWindowsMock.mockReturnValue(true);
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
@@ -181,9 +186,8 @@ describe('<SessionSummaryDisplay />', () => {
);
const output = lastFrame();
// PowerShell doesn't wraps UUID in single quotes because
// it contains no special characters.
expect(output).toContain('gemini --resume 1234-abcd-5678-efgh');
// PowerShell doesn't wrap UUID in quotes by default, but we wrap it in double quotes on Windows.
expect(output).toContain('gemini --resume "1234-abcd-5678-efgh"');
unmount();
});
@@ -201,7 +205,8 @@ describe('<SessionSummaryDisplay />', () => {
);
const output = lastFrame();
// PowerShell wraps in single quotes and escapes internal single quotes by doubling them
// PowerShell wraps in single quotes and escapes internal single quotes by doubling them.
// Since it's already quoted, we don't add redundant double quotes.
expect(output).toContain("gemini --resume '''; rm -rf / #'");
unmount();
});
@@ -8,7 +8,11 @@ import type React from 'react';
import { StatsDisplay } from './StatsDisplay.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core';
import {
escapeShellArg,
getShellConfiguration,
isWindows,
} from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps {
duration: string;
@@ -24,11 +28,17 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
const worktreeSettings = config.getWorktreeSettings();
const escapedSessionId = escapeShellArg(stats.sessionId, shell);
let footer = `To resume this session: gemini --resume ${escapedSessionId}`;
const footerSessionId =
isWindows() &&
!escapedSessionId.startsWith('"') &&
!escapedSessionId.startsWith("'")
? `"${escapedSessionId}"`
: escapedSessionId;
let footer = `To resume this session: gemini --resume ${footerSessionId}`;
if (worktreeSettings) {
footer =
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` +
`To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${footerSessionId}\n` +
`To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`;
}
@@ -141,7 +141,7 @@ describe('ToolConfirmationQueue', () => {
expect(output).toContain('1 of 3');
expect(output).toContain('ls'); // Tool name
expect(output).toContain('list files'); // Tool description
expect(output).toContain('Allow execution of [ls]?');
expect(output).toContain('Allow execution of [Shell]?');
expect(output).toMatchSnapshot();
unmount();
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="734" viewBox="0 0 920 734">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="768" viewBox="0 0 920 768">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="734" fill="#000000" />
<rect width="920" height="768" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -75,91 +75,98 @@
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="376" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="72" y="376" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs"> hostname</text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="393" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="45" y="393" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs"> Current machine hostname</text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="408" width="9" height="17" fill="#001a00" />
<text x="27" y="410" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="408" width="9" height="17" fill="#001a00" />
<rect x="45" y="408" width="27" height="17" fill="#001a00" />
<text x="45" y="410" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<rect x="72" y="408" width="117" height="17" fill="#001a00" />
<text x="72" y="410" fill="#00cd00" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<rect x="189" y="408" width="684" height="17" fill="#001a00" />
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="410" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="425" width="18" height="17" fill="#001a00" />
<rect x="45" y="425" width="513" height="17" fill="#001a00" />
<text x="45" y="427" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<rect x="558" y="425" width="315" height="17" fill="#001a00" />
<text x="45" y="427" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="444" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<rect x="27" y="442" width="9" height="17" fill="#001a00" />
<text x="27" y="444" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="442" width="9" height="17" fill="#001a00" />
<rect x="45" y="442" width="27" height="17" fill="#001a00" />
<text x="45" y="444" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<rect x="72" y="442" width="117" height="17" fill="#001a00" />
<text x="72" y="444" fill="#00cd00" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<rect x="189" y="442" width="684" height="17" fill="#001a00" />
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<rect x="27" y="459" width="18" height="17" fill="#001a00" />
<rect x="45" y="459" width="513" height="17" fill="#001a00" />
<text x="45" y="461" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<rect x="558" y="459" width="315" height="17" fill="#001a00" />
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[]</text>
<text x="72" y="478" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="45" y="478" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="478" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="495" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="45" y="512" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="512" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="546" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="563" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="27" y="597" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="614" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="288" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="396" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="504" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="684" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<rect x="801" y="629" width="36" height="17" fill="#001a00" />
<text x="801" y="631" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
<rect x="837" y="629" width="18" height="17" fill="#001a00" />
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="288" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="396" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="504" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="684" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<rect x="801" y="646" width="27" height="17" fill="#001a00" />
<text x="801" y="648" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
<rect x="828" y="646" width="9" height="17" fill="#001a00" />
<rect x="837" y="646" width="18" height="17" fill="#001a00" />
<text x="837" y="648" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-4</text>
<text x="45" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="665" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="27" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="665" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="288" y="665" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="396" y="665" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="504" y="665" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="684" y="665" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<rect x="801" y="663" width="36" height="17" fill="#001a00" />
<text x="801" y="665" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
<rect x="837" y="663" width="18" height="17" fill="#001a00" />
<text x="864" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="682" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="288" y="682" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="396" y="682" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="504" y="682" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="684" y="682" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<rect x="801" y="680" width="27" height="17" fill="#001a00" />
<text x="801" y="682" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
<rect x="828" y="680" width="9" height="17" fill="#001a00" />
<rect x="837" y="680" width="18" height="17" fill="#001a00" />
<text x="837" y="682" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-4</text>
<text x="864" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="699" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="699" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="699" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="891" y="699" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="716" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="716" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="733" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="734" viewBox="0 0 920 734">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="768" viewBox="0 0 920 768">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="734" fill="#000000" />
<rect width="920" height="768" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -84,77 +84,84 @@
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="376" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="72" y="376" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs"> hostname</text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="393" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="45" y="393" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs"> Current machine hostname</text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="410" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="72" y="410" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="45" y="427" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="444" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="72" y="444" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="45" y="461" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[]</text>
<text x="72" y="478" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="45" y="478" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="478" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="495" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="45" y="512" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="512" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="546" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="563" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="27" y="597" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="614" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="629" width="198" height="17" fill="#001a00" />
<text x="45" y="631" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="315" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="450" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="585" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="783" y="631" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="646" width="126" height="17" fill="#001a00" />
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<rect x="171" y="646" width="72" height="17" fill="#001a00" />
<text x="315" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="450" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="585" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="783" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="45" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="665" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="27" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="663" width="198" height="17" fill="#001a00" />
<text x="45" y="665" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="315" y="665" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="450" y="665" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="585" y="665" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="783" y="665" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">quota</text>
<text x="864" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="680" width="126" height="17" fill="#001a00" />
<text x="45" y="682" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<rect x="171" y="680" width="72" height="17" fill="#001a00" />
<text x="315" y="682" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="450" y="682" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="585" y="682" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="783" y="682" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="864" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="699" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="699" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="699" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="891" y="699" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="716" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="716" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="733" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="717" viewBox="0 0 920 717">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="751" viewBox="0 0 920 751">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="717" fill="#000000" />
<rect width="920" height="751" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -75,76 +75,83 @@
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="376" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="72" y="376" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs"> hostname</text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="393" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="45" y="393" fill="#afafaf" textLength="225" lengthAdjust="spacingAndGlyphs"> Current machine hostname</text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="410" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="72" y="410" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="45" y="427" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="444" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="72" y="444" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="45" y="461" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="476" width="9" height="17" fill="#001a00" />
<text x="27" y="478" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="476" width="9" height="17" fill="#001a00" />
<rect x="45" y="476" width="27" height="17" fill="#001a00" />
<text x="45" y="478" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<rect x="72" y="476" width="171" height="17" fill="#001a00" />
<text x="72" y="478" fill="#00cd00" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<rect x="243" y="476" width="630" height="17" fill="#001a00" />
<text x="72" y="478" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="493" width="846" height="17" fill="#001a00" />
<text x="45" y="495" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<rect x="27" y="510" width="9" height="17" fill="#001a00" />
<text x="27" y="512" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="510" width="9" height="17" fill="#001a00" />
<rect x="45" y="510" width="27" height="17" fill="#001a00" />
<text x="45" y="512" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<rect x="72" y="510" width="171" height="17" fill="#001a00" />
<text x="72" y="512" fill="#00cd00" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<rect x="243" y="510" width="630" height="17" fill="#001a00" />
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="527" width="846" height="17" fill="#001a00" />
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="546" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="563" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="27" y="597" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="614" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="207" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="270" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="342" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="405" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="495" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="558" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="720" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="783" y="631" fill="#afafaf" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="648" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="665" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="207" y="665" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="270" y="665" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="342" y="665" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="405" y="665" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="495" y="665" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="558" y="665" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="720" y="665" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="783" y="665" fill="#afafaf" textLength="72" lengthAdjust="spacingAndGlyphs">42% used</text>
<text x="864" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="682" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="682" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="891" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="699" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="699" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="716" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -23,6 +23,8 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] hostname │
│ Current machine hostname │
│ [ ] auth │
│ Current authentication info │
│ > [✓] code-changes │
@@ -68,6 +70,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] hostname │
│ Current machine hostname │
│ [ ] auth │
│ Current authentication info │
│ [ ] code-changes │
@@ -114,6 +118,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] hostname │
│ Current machine hostname │
│ [ ] auth │
│ Current authentication info │
│ [ ] code-changes │
@@ -159,6 +165,8 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] hostname │
│ Current machine hostname │
│ [ ] auth │
│ Current authentication info │
│ [ ] code-changes │
@@ -84,8 +84,8 @@
<text x="711" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs"> Allow execution of </text>
<text x="189" y="308" fill="#ffffaf" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">[echo]</text>
<text x="243" y="308" fill="#ffffff" textLength="468" lengthAdjust="spacingAndGlyphs">? </text>
<text x="189" y="308" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
<text x="252" y="308" fill="#ffffff" textLength="459" lengthAdjust="spacingAndGlyphs">? </text>
<text x="711" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="711" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -191,8 +191,8 @@
<text x="711" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="9" y="563" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs"> Allow execution of </text>
<text x="189" y="563" fill="#ffffaf" textLength="54" lengthAdjust="spacingAndGlyphs" font-weight="bold">[echo]</text>
<text x="243" y="563" fill="#ffffff" textLength="468" lengthAdjust="spacingAndGlyphs">? </text>
<text x="189" y="563" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
<text x="252" y="563" fill="#ffffff" textLength="459" lengthAdjust="spacingAndGlyphs">? </text>
<text x="711" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="711" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -52,7 +52,7 @@ exports[`ToolConfirmationQueue > height allocation and layout > should handle se
│ Original: https://täst.com/ │
│ Actual Host (Punycode): https://xn--tst-qla.com/ │
│ │
│ Allow execution of [echo]?
│ Allow execution of [Shell]? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
@@ -138,7 +138,7 @@ exports[`ToolConfirmationQueue > height allocation and layout > should render th
│ │ echo "Line 49" │ │
│ │ echo "Line 50" │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│ Allow execution of [echo]?
│ Allow execution of [Shell]? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
@@ -202,7 +202,7 @@ exports[`ToolConfirmationQueue > renders the confirming tool with progress indic
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
│ │ ls │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│ Allow execution of [ls]?
│ Allow execution of [Shell]?
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import { render } from '../../../test-utils/render.js';
import { Text } from 'ink';
import { describe, it, expect, vi } from 'vitest';
import { ExportSessionMessage } from './ExportSessionMessage.js';
vi.mock('../CliSpinner.js', () => ({
CliSpinner: () => <Text>[spinner]</Text>,
}));
describe('ExportSessionMessage', () => {
it('renders pending state correctly', async () => {
const { lastFrame } = await render(
<ExportSessionMessage exportSession={{ isPending: true }} />,
);
expect(lastFrame()).toContain('[spinner]');
expect(lastFrame()).toContain('Exporting session...');
});
it('renders success state correctly', async () => {
const testPath = path.join(path.sep, 'path', 'to', 'session.json');
const { lastFrame } = await render(
<ExportSessionMessage
exportSession={{
isPending: false,
targetPath: testPath,
}}
/>,
);
expect(lastFrame()).toContain('✓');
expect(lastFrame()).toContain(
`Successfully exported session to ${testPath}`,
);
});
});
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { JSX } from 'react';
import { Box, Text } from 'ink';
import type { ExportSessionProps } from '../../types.js';
import { CliSpinner } from '../CliSpinner.js';
import { theme } from '../../semantic-colors.js';
export interface ExportSessionDisplayProps {
exportSession: ExportSessionProps;
}
/*
* Export session messages appear when the /export-session command is run, and show a loading spinner
* while export is in progress, followed by a success message.
*/
export function ExportSessionMessage({
exportSession,
}: ExportSessionDisplayProps): JSX.Element {
const { isPending, targetPath } = exportSession;
return (
<Box flexDirection="row" marginTop={1}>
<Box marginRight={1}>
{isPending ? (
<CliSpinner type="dots" />
) : (
<Text color={theme.status.success}></Text>
)}
</Box>
<Box>
<Text color={isPending ? theme.text.accent : theme.status.success}>
{isPending
? 'Exporting session...'
: `Successfully exported session to ${targetPath}`}
</Text>
</Box>
</Box>
);
}
@@ -275,6 +275,108 @@ describe('ToolConfirmationMessage', () => {
result.unmount();
});
it('should use the tool name for display in the confirmation question', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: '# This is a comment\necho "hello"',
rootCommand: 'echo',
rootCommands: ['echo'],
};
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
toolName="shell"
/>,
);
const output = lastFrame();
expect(output).toContain('Allow execution of [Shell]?');
unmount();
});
describe('tool name humanization', () => {
const cases = [
{
toolName: 'run_shell_command',
expected: 'Allow execution of [Shell]?',
desc: 'humanize run_shell_command to Shell',
},
{
toolName: 'shell',
expected: 'Allow execution of [Shell]?',
desc: 'humanize shell to Shell',
},
{
toolName: 'grep_search',
expected: 'Allow execution of [grep_search]?',
desc: 'keep raw name for non-shell tools',
},
];
for (const { toolName, expected, desc } of cases) {
it(`should ${desc}`, async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
};
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
toolName={toolName}
/>,
);
expect(lastFrame()).toContain(expected);
unmount();
});
}
it('should humanize shell tool in sandbox expansion prompt', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'sandbox_expansion',
title: 'Confirm',
command: 'ls',
rootCommand: 'ls',
additionalPermissions: {
network: true,
},
};
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
toolName="run_shell_command"
/>,
);
expect(lastFrame()).toContain(
'To run [Shell], allow access to the following?',
);
unmount();
});
});
describe('with folder trust', () => {
const editConfirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
@@ -629,18 +629,13 @@ export const ToolConfirmationMessage: React.FC<
);
}
} else if (confirmationDetails.type === 'sandbox_expansion') {
const { additionalPermissions, command, rootCommand } =
confirmationDetails;
const { additionalPermissions, command } = confirmationDetails;
const readPaths = additionalPermissions?.fileSystem?.read || [];
const writePaths = additionalPermissions?.fileSystem?.write || [];
const network = additionalPermissions?.network;
const isShell = isShellTool(toolName);
const rootCmds = rootCommand
.split(',')
.map((c) => c.trim().split(/\s+/)[0])
.filter((c) => c && !c.startsWith('redirection'));
const commandNames = Array.from(new Set(rootCmds)).join(', ');
const commandNames = isShell ? 'Shell' : toolName;
question = '';
bodyContent = (
@@ -730,13 +725,7 @@ export const ToolConfirmationMessage: React.FC<
);
}
const commandNames = Array.from(
new Set(
commandsToDisplay
.map((cmd) => cmd.trim().split(/\s+/)[0])
.filter(Boolean),
),
).join(', ');
const commandNames = isShell ? 'Shell' : toolName;
const allowQuestion = (
<Text>
@@ -32,10 +32,12 @@ export const STATUS_INDICATOR_WIDTH = 3;
* Returns true if the tool name corresponds to a shell tool.
*/
export function isShellTool(name: string): boolean {
const normalized = name.toLowerCase();
return (
name === SHELL_COMMAND_NAME ||
name === SHELL_NAME ||
name === SHELL_TOOL_NAME
name === SHELL_TOOL_NAME ||
normalized === 'shell'
);
}
@@ -4,7 +4,7 @@ exports[`ToolConfirmationMessage Redirection > should display redirection warnin
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" > test.txt │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
Allow execution of [Shell]?
Redirection detected. To auto-accept, press Shift+Tab
● 1. Allow once
@@ -133,7 +133,9 @@
<text x="63" y="546" fill="#cdcd00" textLength="81" lengthAdjust="spacingAndGlyphs">&quot;Line 50&quot;</text>
<text x="711" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="580" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of [echo]? </text>
<text x="0" y="580" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs">Allow execution of </text>
<text x="171" y="580" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
<text x="234" y="580" fill="#ffffff" textLength="666" lengthAdjust="spacingAndGlyphs">? </text>
<rect x="0" y="612" width="9" height="17" fill="#001a00" />
<text x="0" y="614" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="612" width="9" height="17" fill="#001a00" />

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -24,7 +24,9 @@
<text x="18" y="70" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">done</text>
<text x="711" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#333333" textLength="720" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="104" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of [echo]? </text>
<text x="0" y="104" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs">Allow execution of </text>
<text x="171" y="104" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
<text x="234" y="104" fill="#ffffff" textLength="666" lengthAdjust="spacingAndGlyphs">? </text>
<rect x="0" y="136" width="9" height="17" fill="#001a00" />
<text x="0" y="138" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="9" y="136" width="9" height="17" fill="#001a00" />

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -94,7 +94,7 @@ exports[`ToolConfirmationMessage > height allocation and layout > should expand
│ echo "Line 49" │
│ echo "Line 50" │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
Allow execution of [Shell]?
● 1. Allow once
2. Allow for this session
@@ -110,7 +110,7 @@ exports[`ToolConfirmationMessage > should display multiple commands for exec typ
│ │
│ whoami │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo, ls, whoami]?
Allow execution of [Shell]?
● 1. Allow once
2. Allow for this session
@@ -148,7 +148,7 @@ exports[`ToolConfirmationMessage > should render multiline shell scripts with co
│ echo $i │
│ done │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
Allow execution of [Shell]?
● 1. Allow once
2. Allow for this session
@@ -200,7 +200,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"╭──────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
Allow execution of [Shell]?
● 1. Allow once
2. No, suggest changes (esc)
@@ -211,7 +211,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
"╭──────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
Allow execution of [Shell]?
● 1. Allow once
2. Allow for this session
+7 -6
View File
@@ -75,13 +75,9 @@ export function useVoiceMode({
}
const serviceToDisconnect = transcriptionServiceRef.current;
transcriptionServiceRef.current = null;
if (serviceToDisconnect) {
const isLive = settings.experimental.voice?.backend === 'gemini-live';
const gracePeriodMs =
settings.experimental.voice?.stopGracePeriodMs ??
(isLive ? 2000 : 1000);
const gracePeriodMs = settings.experimental.voice.stopGracePeriodMs;
debugLogger.debug(
`[Voice] Draining transcription for ${gracePeriodMs}ms`,
);
@@ -90,11 +86,16 @@ export function useVoiceMode({
disconnectTimerRef.current = setTimeout(() => {
debugLogger.debug('[Voice] Grace period ended, disconnecting service');
serviceToDisconnect.disconnect();
if (transcriptionServiceRef.current === serviceToDisconnect) {
transcriptionServiceRef.current = null;
}
disconnectTimerRef.current = null;
liveTranscriptionRef.current = '';
}, gracePeriodMs);
} else {
liveTranscriptionRef.current = '';
}
liveTranscriptionRef.current = '';
pttStateRef.current = 'idle';
}, [settings.experimental.voice]);
@@ -206,7 +206,6 @@ class ThemeManager {
try {
const theme = createCustomTheme(themeWithDefaults);
this.extensionThemes.set(namespacedName, theme);
debugLogger.log(`Registered theme: ${namespacedName}`);
} catch (error) {
debugLogger.warn(
`Failed to load custom theme "${namespacedName}":`,
@@ -237,7 +236,6 @@ class ThemeManager {
for (const theme of customThemes) {
const namespacedName = `${theme.name} (${extensionName})`;
this.extensionThemes.delete(namespacedName);
debugLogger.log(`Unregistered theme: ${namespacedName}`);
}
}
+12
View File
@@ -148,6 +148,11 @@ export interface CompressionProps {
compressionStatus: CompressionStatus | null;
}
export interface ExportSessionProps {
isPending: boolean;
targetPath?: string;
}
/**
* For use when you want no icon.
*/
@@ -284,6 +289,11 @@ export type HistoryItemCompression = HistoryItemBase & {
compression: CompressionProps;
};
export type HistoryItemExportSession = HistoryItemBase & {
type: 'export_session';
exportSession: ExportSessionProps;
};
export type HistoryItemExtensionsList = HistoryItemBase & {
type: 'extensions_list';
extensions: GeminiCLIExtension[];
@@ -427,6 +437,7 @@ export type HistoryItemWithoutId =
| HistoryItemModel
| HistoryItemQuit
| HistoryItemCompression
| HistoryItemExportSession
| HistoryItemExtensionsList
| HistoryItemToolsList
| HistoryItemSkillsList
@@ -454,6 +465,7 @@ export enum MessageType {
QUIT = 'quit',
GEMINI = 'gemini',
COMPRESSION = 'compression',
EXPORT_SESSION = 'export_session',
EXTENSIONS_LIST = 'extensions_list',
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
@@ -7,6 +7,10 @@
import * as fsPromises from 'node:fs/promises';
import path from 'node:path';
import type { Content } from '@google/genai';
import type {
ConversationRecord,
MessageRecord,
} from '@google/gemini-cli-core';
/**
* Serializes chat history to a Markdown string.
@@ -51,8 +55,17 @@ export function serializeHistoryToMarkdown(
* Options for exporting chat history.
*/
export interface ExportHistoryOptions {
/** The standard history array used for model requests. */
history: readonly Content[];
/** The file path to export to. */
filePath: string;
/** Optional subagent trajectories to include. */
trajectories?: Record<string, ConversationRecord>;
/**
* Optional full message records which contain metadata like agentId for tool calls,
* providing the link between history and trajectories.
*/
messages?: MessageRecord[];
}
/**
@@ -61,12 +74,16 @@ export interface ExportHistoryOptions {
export async function exportHistoryToFile(
options: ExportHistoryOptions,
): Promise<void> {
const { history, filePath } = options;
const { history, filePath, trajectories, messages } = options;
const extension = path.extname(filePath).toLowerCase();
let content: string;
if (extension === '.json') {
content = JSON.stringify(history, null, 2);
if (trajectories && Object.keys(trajectories).length > 0) {
content = JSON.stringify({ history, messages, trajectories }, null, 2);
} else {
content = JSON.stringify(history, null, 2);
}
} else if (extension === '.md') {
content = serializeHistoryToMarkdown(history);
} else {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
geminiPartsToContentParts,
contentPartsToGeminiParts,
@@ -12,6 +12,7 @@ import {
} from './content-utils.js';
import type { Part } from '@google/genai';
import type { ContentPart } from './types.js';
import { debugLogger } from '../utils/debugLogger.js';
describe('geminiPartsToContentParts', () => {
it('converts text parts', () => {
@@ -191,11 +192,17 @@ describe('contentPartsToGeminiParts', () => {
const content = [
{ type: 'custom_widget', payload: 123 },
] as unknown as ContentPart[];
const warnSpy = vi.spyOn(debugLogger, 'warn');
const result = contentPartsToGeminiParts(content);
expect(warnSpy).toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
text: JSON.stringify({ type: 'custom_widget', payload: 123 }),
});
warnSpy.mockRestore();
});
});
+4
View File
@@ -6,6 +6,7 @@
import type { Part } from '@google/genai';
import type { ContentPart } from './types.js';
import { debugLogger } from '../utils/debugLogger.js';
/**
* Converts Gemini API Part objects to framework-agnostic ContentPart objects.
@@ -93,6 +94,9 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
result.push({ text: part.text });
break;
default:
debugLogger.warn(
`Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`,
);
// Serialize unknown ContentPart variants instead of dropping them
result.push({ text: JSON.stringify(part) });
break;
@@ -1330,6 +1330,7 @@ describe('LegacyAgentSession', () => {
);
expect(err?.message).toBe('Connection refused');
expect(err?.fatal).toBe(true);
expect(err?._meta?.['stack']).toBeDefined();
const streamEnd = events.find(
(e): e is AgentEvent<'agent_end'> => e.type === 'agent_end',
@@ -166,6 +166,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
} else {
this._emitErrorAndAgentEnd(err);
}
} finally {
this._clearActiveStream();
}
}
@@ -390,6 +391,7 @@ export class LegacyAgentProtocol implements AgentProtocol {
const meta: Record<string, unknown> = {};
if (err instanceof Error) {
meta['errorName'] = err.constructor.name;
meta['stack'] = err.stack;
if ('exitCode' in err && typeof err.exitCode === 'number') {
meta['exitCode'] = err.exitCode;
}
@@ -2227,6 +2227,69 @@ describe('LocalAgentExecutor', () => {
// Agent should terminate with ABORTED status
expect(output.terminate_reason).toBe(AgentTerminateMode.ABORTED);
});
it('should throw a critical error when a tool response is dropped by the scheduler', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Turn 1: Model calls two tools
mockModelResponse([
{ name: LS_TOOL_NAME, args: { path: 'dir1' }, id: 'call1' },
{ name: LS_TOOL_NAME, args: { path: 'dir2' }, id: 'call2' },
]);
// Simulate scheduler returning only ONE result for TWO calls (dropped response)
mockScheduleAgentTools.mockResolvedValueOnce([
{
status: 'success',
request: { callId: 'call1', name: LS_TOOL_NAME },
response: {
responseParts: [
{
functionResponse: {
name: LS_TOOL_NAME,
id: 'call1',
response: { ok: true },
},
},
],
},
},
]);
await expect(
executor.run({ goal: 'Protocol test' }, signal),
).rejects.toThrow(
'Critical System Failure: Tool execution result was lost/dropped by the scheduler',
);
});
it('should throw a critical error when all scheduler results are missing/dropped', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
// Turn 1: Model calls one tool
mockModelResponse([
{ name: LS_TOOL_NAME, args: { path: 'dir1' }, id: 'call1' },
]);
// Simulate scheduler returning NO results (dropped response)
mockScheduleAgentTools.mockResolvedValueOnce([]);
await expect(
executor.run({ goal: 'Protocol test 2' }, signal),
).rejects.toThrow(
'Critical System Failure: Tool execution result was lost/dropped by the scheduler',
);
});
});
describe('Model Routing', () => {
@@ -2279,6 +2342,97 @@ describe('LocalAgentExecutor', () => {
);
});
it('should cache the routing decision across multiple turns', async () => {
const definition = createTestDefinition();
definition.modelConfig.model = 'auto';
definition.runConfig.maxTurns = 3;
const mockRouter = {
route: vi.fn().mockResolvedValue({
model: 'routed-model',
metadata: { source: 'test', reasoning: 'test' },
}),
};
vi.spyOn(mockConfig, 'getModelRouterService').mockReturnValue(
mockRouter as unknown as ModelRouterService,
);
vi.spyOn(
mockConfig.modelConfigService,
'getResolvedConfig',
).mockReturnValue({
model: 'auto',
generateContentConfig: {},
} as unknown as ResolvedModelConfig);
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
mockModelResponse([
{
name: LS_TOOL_NAME,
args: {},
id: 'call1',
},
]);
mockModelResponse([
{
name: COMPLETE_TASK_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call2',
},
]);
mockScheduleAgentTools.mockResolvedValueOnce([
{
status: 'success',
request: {
callId: 'call1',
name: LS_TOOL_NAME,
args: {},
prompt_id: 'test-prompt',
},
response: {
resultDisplay: 'ls result',
responseParts: [
{
functionResponse: {
name: LS_TOOL_NAME,
id: 'call1',
response: { ok: true },
},
},
],
data: {},
},
},
]);
await executor.run({ goal: 'test' }, signal);
expect(mockRouter.route).toHaveBeenCalledTimes(1);
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ model: 'routed-model' }),
expect.any(Array),
expect.any(String),
expect.any(AbortSignal),
LlmRole.SUBAGENT,
);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ model: 'routed-model' }),
expect.any(Array),
expect.any(String),
expect.any(AbortSignal),
LlmRole.SUBAGENT,
);
});
it('should NOT use model routing when the agent model is NOT "auto"', async () => {
const definition = createTestDefinition();
definition.modelConfig.model = 'concrete-model';
+39 -30
View File
@@ -62,6 +62,7 @@ import { getErrorMessage } from '../utils/errors.js';
import { templateString } from './utils.js';
import { DEFAULT_GEMINI_MODEL, isAutoModel } from '../config/models.js';
import type { RoutingContext } from '../routing/routingStrategy.js';
import { LRUCache } from 'mnemonist';
import { parseThought } from '../utils/thoughtUtils.js';
import { type z } from 'zod';
import { debugLogger } from '../utils/debugLogger.js';
@@ -127,6 +128,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private readonly compressionService: ChatCompressionService;
private readonly parentCallId?: string;
private hasFailedCompressionAttempt = false;
private cache: LRUCache<string, string>;
private get executionContext(): AgentLoopContext {
return {
@@ -311,6 +313,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.onActivity = onActivity;
this.compressionService = new ChatCompressionService();
this.parentCallId = parentCallId;
this.cache = new LRUCache<string, string>(10);
this.agentId = Math.random().toString(36).slice(2, 8);
}
@@ -949,26 +952,28 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
});
const requestedModel = resolvedConfig.model;
let modelToUse: string;
let modelToUse: string | undefined;
if (isAutoModel(requestedModel)) {
// TODO(joshualitt): This try / catch is inconsistent with the routing
// behavior for the main agent. Ideally, we would have a universal
// policy for routing failure. Given routing failure does not necessarily
// mean generation will fail, we may want to share this logic with
// other places we use model routing.
try {
const routingContext: RoutingContext = {
history: chat.getHistory(/*curated=*/ true),
request: message.parts || [],
signal,
requestedModel,
};
const router = this.context.config.getModelRouterService();
const decision = await router.route(routingContext);
modelToUse = decision.model;
} catch (error) {
debugLogger.warn(`Error during model routing: ${error}`);
modelToUse = DEFAULT_GEMINI_MODEL;
modelToUse = this.cache.get('modelToUse');
// If not cached, fetch from the router and cache the result.
if (!modelToUse) {
try {
const routingContext: RoutingContext = {
history: chat.getHistory(/*curated=*/ true),
request: message.parts || [],
signal,
requestedModel,
};
const router = this.context.config.getModelRouterService();
const decision = await router.route(routingContext);
modelToUse = decision.model;
} catch (error) {
debugLogger.warn(`Error during model routing: ${error}`);
modelToUse = DEFAULT_GEMINI_MODEL;
}
// Cache the result regardless of whether it succeeded or fell back
this.cache.set('modelToUse', modelToUse);
}
} else {
modelToUse = requestedModel;
@@ -1282,25 +1287,29 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
}
}
// Reconstruct toolResponseParts in the original order
// Ensure exactly one response per function call to satisfy the Gemini API protocol.
const toolResponseParts: Part[] = [];
for (const [index, functionCall] of functionCalls.entries()) {
const callId = functionCall.id ?? `${promptId}-${index}`;
const part = syncResults.get(callId);
if (part) {
toolResponseParts.push(part);
continue;
}
}
// If all authorized tool calls failed (and task isn't complete), provide a generic error.
if (
functionCalls.length > 0 &&
toolResponseParts.length === 0 &&
!taskCompleted
) {
toolResponseParts.push({
text: 'All tool calls failed or were unauthorized. Please analyze the errors and try an alternative approach.',
});
const isAborted = signal.aborted;
const isTaskComplete =
functionCall.name === COMPLETE_TASK_TOOL_NAME && taskCompleted;
// Safely skip missing responses if the run was interrupted or the turn won't be sent back.
if (isAborted || isTaskComplete) {
continue;
}
throw new Error(
`[LocalAgentExecutor] Critical System Failure: Tool execution result was lost/dropped by the scheduler for callId ${callId} (${functionCall.name}). This indicates an internal race condition or scheduler bug.`,
);
}
return {
@@ -0,0 +1,967 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { LocalSubagentSession } from './local-subagent-protocol.js';
import { LocalAgentExecutor } from './local-executor.js';
import {
AgentTerminateMode,
type LocalAgentDefinition,
type SubagentActivityEvent,
} from './types.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { AgentEvent } from '../agent/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { z } from 'zod';
import type { Mocked } from 'vitest';
vi.mock('./local-executor.js');
const MockLocalAgentExecutor = vi.mocked(LocalAgentExecutor);
// Captures the onActivity callback passed to LocalAgentExecutor.create().
// Set via create.mockImplementation in beforeEach to avoid mock.calls index fragility.
let capturedOnActivity: ((activity: SubagentActivityEvent) => void) | undefined;
const testDefinition: LocalAgentDefinition = {
kind: 'local',
name: 'TestProtocolAgent',
description: 'A test agent for protocol tests.',
inputConfig: {
inputSchema: {
type: 'object',
properties: {
task: { type: 'string' },
priority: { type: 'number' },
},
},
},
modelConfig: { model: 'test', generateContentConfig: {} },
runConfig: { maxTimeMinutes: 1 },
promptConfig: { systemPrompt: 'test' },
};
const GOAL_OUTPUT = {
result: 'Analysis complete.',
terminate_reason: AgentTerminateMode.GOAL,
};
describe('LocalSubagentSession (protocol)', () => {
let mockContext: AgentLoopContext;
let mockMessageBus: MessageBus;
let mockExecutorInstance: Mocked<LocalAgentExecutor<z.ZodUnknown>>;
beforeEach(() => {
vi.clearAllMocks();
capturedOnActivity = undefined;
mockContext = makeFakeConfig() as unknown as AgentLoopContext;
mockMessageBus = createMockMessageBus();
mockExecutorInstance = {
run: vi.fn().mockResolvedValue(GOAL_OUTPUT),
definition: testDefinition,
} as unknown as Mocked<LocalAgentExecutor<z.ZodUnknown>>;
// Use mockImplementation (not mockResolvedValue) so we can capture onActivity.
MockLocalAgentExecutor.create.mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async (_def: any, _ctx: any, onActivity: any) => {
capturedOnActivity = onActivity;
return mockExecutorInstance as unknown as LocalAgentExecutor<z.ZodTypeAny>;
},
);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Lifecycle events
// ---------------------------------------------------------------------------
describe('lifecycle events', () => {
it('emits agent_start then agent_end(completed) for a GOAL run', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'query' }] },
});
await session.getResult();
expect(events[0].type).toBe('agent_start');
expect(events[events.length - 1].type).toBe('agent_end');
const endEvent = events[events.length - 1];
if (endEvent.type === 'agent_end') {
expect(endEvent.reason).toBe('completed');
}
});
it('emits agent_start exactly once even if ensureAgentStart called twice internally', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'query' }] },
});
await session.getResult();
const startEvents = events.filter((e) => e.type === 'agent_start');
expect(startEvents).toHaveLength(1);
});
it('emits agent_end exactly once on error path', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
mockExecutorInstance.run.mockRejectedValue(new Error('executor failed'));
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'query' }] },
});
await expect(session.getResult()).rejects.toThrow('executor failed');
const endEvents = events.filter((e) => e.type === 'agent_end');
expect(endEvents).toHaveLength(1);
});
it('all events share the same streamId', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'query' }] },
});
await session.getResult();
const streamIds = new Set(events.map((e) => e.streamId));
expect(streamIds.size).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Config buffering (update + message pattern)
// ---------------------------------------------------------------------------
describe('config buffering', () => {
it('merges buffered config with message query', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
await session.send({
update: { config: { task: 'analyze', priority: 5 } },
});
await session.send({
message: { content: [{ type: 'text', text: 'my query' }] },
});
await session.getResult();
expect(mockExecutorInstance.run).toHaveBeenCalledWith(
{ task: 'analyze', priority: 5, query: 'my query' },
expect.any(AbortSignal),
);
});
it('omits query key when message text is empty', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
await session.send({ update: { config: { task: 'no-query-task' } } });
await session.send({
message: { content: [{ type: 'text', text: '' }] },
});
await session.getResult();
const callArgs = mockExecutorInstance.run.mock.calls[0][0];
expect(callArgs).not.toHaveProperty('query');
expect(callArgs).toEqual({ task: 'no-query-task' });
});
it('sends only query when no prior update', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'just a query' }] },
});
await session.getResult();
expect(mockExecutorInstance.run).toHaveBeenCalledWith(
{ query: 'just a query' },
expect.any(AbortSignal),
);
});
it('multiple update calls are merged', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
await session.send({ update: { config: { field1: 'a' } } });
await session.send({ update: { config: { field2: 'b' } } });
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(mockExecutorInstance.run).toHaveBeenCalledWith(
{ field1: 'a', field2: 'b', query: 'q' },
expect.any(AbortSignal),
);
});
it('update returns streamId: null; message returns a streamId', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const updateResult = await session.send({ update: { config: {} } });
expect(updateResult.streamId).toBeNull();
const messageResult = await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
expect(messageResult.streamId).not.toBeNull();
expect(typeof messageResult.streamId).toBe('string');
// Await completion to prevent dangling execution affecting subsequent tests
await session.getResult();
});
});
// ---------------------------------------------------------------------------
// Activity translation
// ---------------------------------------------------------------------------
describe('activity translation', () => {
function makeSession() {
const activityEvents: SubagentActivityEvent[] = [];
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
return { session, activityEvents };
}
async function runWithActivities(
session: LocalSubagentSession,
activities: SubagentActivityEvent[],
) {
mockExecutorInstance.run.mockImplementation(async () => {
// capturedOnActivity is set by the create.mockImplementation in beforeEach
// and updated whenever create() is called. By the time run() is called,
// capturedOnActivity holds the onActivity closure for the most-recently
// created executor — which is the one associated with this session.
for (const act of activities) {
capturedOnActivity?.(act);
}
return GOAL_OUTPUT;
});
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
return events;
}
it('THOUGHT_CHUNK → message event with thought content', async () => {
const { session } = makeSession();
const events = await runWithActivities(session, [
{
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'I am thinking...' },
},
]);
const msgEvent = events.find((e) => e.type === 'message');
expect(msgEvent).toBeDefined();
if (msgEvent?.type === 'message') {
expect(msgEvent.role).toBe('agent');
expect(msgEvent.content).toContainEqual({
type: 'thought',
thought: 'I am thinking...',
});
}
});
it('TOOL_CALL_START → tool_request event', async () => {
const { session } = makeSession();
const events = await runWithActivities(session, [
{
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'TOOL_CALL_START',
data: { callId: 'call-123', name: 'read_file', args: { path: '/a' } },
},
]);
const reqEvent = events.find((e) => e.type === 'tool_request');
expect(reqEvent).toBeDefined();
if (reqEvent?.type === 'tool_request') {
expect(reqEvent.requestId).toBe('call-123');
expect(reqEvent.name).toBe('read_file');
expect(reqEvent.args).toEqual({ path: '/a' });
}
});
it('TOOL_CALL_END → tool_response event', async () => {
const { session } = makeSession();
const events = await runWithActivities(session, [
{
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'TOOL_CALL_END',
data: { id: 'call-123', name: 'read_file', output: 'file contents' },
},
]);
const respEvent = events.find((e) => e.type === 'tool_response');
expect(respEvent).toBeDefined();
if (respEvent?.type === 'tool_response') {
expect(respEvent.requestId).toBe('call-123');
expect(respEvent.name).toBe('read_file');
expect(respEvent.content).toContainEqual({
type: 'text',
text: 'file contents',
});
}
});
it('ERROR activity → error event with INTERNAL status, fatal: false', async () => {
const { session } = makeSession();
const events = await runWithActivities(session, [
{
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'ERROR',
data: { error: 'something went wrong' },
},
]);
const errEvent = events.find((e) => e.type === 'error');
expect(errEvent).toBeDefined();
if (errEvent?.type === 'error') {
expect(errEvent.status).toBe('INTERNAL');
expect(errEvent.message).toBe('something went wrong');
expect(errEvent.fatal).toBe(false);
}
});
it('unknown activity type → no events emitted', async () => {
const { session } = makeSession();
const events = await runWithActivities(session, [
{
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: 'UNKNOWN_TYPE' as any,
data: {},
},
]);
// Only agent_start and agent_end should be present
const nonLifecycle = events.filter(
(e) => e.type !== 'agent_start' && e.type !== 'agent_end',
);
expect(nonLifecycle).toHaveLength(0);
});
it('TOOL_CALL_START with non-object args defaults to {}', async () => {
const { session } = makeSession();
const events = await runWithActivities(session, [
{
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'TOOL_CALL_START',
data: { callId: 'x', name: 'tool', args: null },
},
]);
const reqEvent = events.find((e) => e.type === 'tool_request');
if (reqEvent?.type === 'tool_request') {
expect(reqEvent.args).toEqual({});
}
});
});
// ---------------------------------------------------------------------------
// getResult() promise
// ---------------------------------------------------------------------------
describe('getResult()', () => {
it('resolves with OutputObject on GOAL termination', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
const output = await session.getResult();
expect(output.result).toBe('Analysis complete.');
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
});
it('rejects when executor throws', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
mockExecutorInstance.run.mockRejectedValue(new Error('executor error'));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow('executor error');
});
});
// ---------------------------------------------------------------------------
// rawActivityCallback
// ---------------------------------------------------------------------------
describe('rawActivityCallback', () => {
it('receives raw SubagentActivityEvent before AgentEvent translation', async () => {
const rawActivities: SubagentActivityEvent[] = [];
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
(activity) => rawActivities.push(activity),
);
const thoughtActivity: SubagentActivityEvent = {
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'raw thought' },
};
mockExecutorInstance.run.mockImplementation(async () => {
const onActivity = MockLocalAgentExecutor.create.mock.calls[0]?.[2];
onActivity?.(thoughtActivity);
return GOAL_OUTPUT;
});
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(rawActivities).toHaveLength(1);
expect(rawActivities[0]).toBe(thoughtActivity);
});
it('is called before AgentEvent translation (raw arrives first)', async () => {
const callOrder: string[] = [];
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
() => callOrder.push('raw'),
);
session.subscribe((e) => {
if (e.type === 'message') callOrder.push('translated');
});
mockExecutorInstance.run.mockImplementation(async () => {
const onActivity = MockLocalAgentExecutor.create.mock.calls[0]?.[2];
onActivity?.({
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'thought' },
});
return GOAL_OUTPUT;
});
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(callOrder).toEqual(['raw', 'translated']);
});
it('is optional — no callback causes no error', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
// no rawActivityCallback
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).resolves.toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Subscription
// ---------------------------------------------------------------------------
describe('subscription', () => {
it('unsubscribe stops event delivery', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const received: AgentEvent[] = [];
const unsub = session.subscribe((e) => received.push(e));
unsub();
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(received).toHaveLength(0);
});
it('multiple subscribers all receive events', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const received1: AgentEvent[] = [];
const received2: AgentEvent[] = [];
session.subscribe((e) => received1.push(e));
session.subscribe((e) => received2.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(received1.length).toBeGreaterThan(0);
expect(received1).toEqual(received2);
});
it('events array accumulates all emitted events', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(session.events.length).toBeGreaterThanOrEqual(2); // at least agent_start + agent_end
expect(session.events[0].type).toBe('agent_start');
});
});
// ---------------------------------------------------------------------------
// Terminate mode mapping
// ---------------------------------------------------------------------------
describe('terminate mode → StreamEndReason mapping', () => {
const cases: Array<[AgentTerminateMode, string]> = [
[AgentTerminateMode.GOAL, 'completed'],
[AgentTerminateMode.TIMEOUT, 'max_time'],
[AgentTerminateMode.MAX_TURNS, 'max_turns'],
[AgentTerminateMode.ABORTED, 'aborted'],
[AgentTerminateMode.ERROR, 'failed'],
[AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL, 'failed'],
];
for (const [terminateMode, expectedReason] of cases) {
it(`${terminateMode} → agent_end(reason:'${expectedReason}')`, async () => {
mockExecutorInstance.run.mockResolvedValue({
result: 'done',
terminate_reason: terminateMode,
});
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult().catch(() => {
// ABORTED results in rejection — catch to let test complete
});
const endEvent = events.find((e) => e.type === 'agent_end');
expect(endEvent).toBeDefined();
if (endEvent?.type === 'agent_end') {
expect(endEvent.reason).toBe(expectedReason);
}
});
}
});
// ---------------------------------------------------------------------------
// Abort
// ---------------------------------------------------------------------------
describe('abort()', () => {
it('abort() causes agent_end(reason:aborted)', async () => {
// Make run() wait until aborted
let abortSignal: AbortSignal | undefined;
mockExecutorInstance.run.mockImplementation(
(_params: unknown, signal: AbortSignal) => {
abortSignal = signal;
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => {
const err = new Error('AbortError');
err.name = 'AbortError';
reject(err);
});
});
},
);
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
void session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
// Wait for executor to be created and run started
await vi.waitFor(() => {
expect(abortSignal).toBeDefined();
});
await session.abort();
const result = await session.getResult();
expect(result.result).toBe('');
expect(result.terminate_reason).toBe('ABORTED');
const endEvent = events.find((e) => e.type === 'agent_end');
expect(endEvent).toBeDefined();
if (endEvent?.type === 'agent_end') {
expect(endEvent.reason).toBe('aborted');
}
});
});
// ---------------------------------------------------------------------------
// Full event sequence
// ---------------------------------------------------------------------------
describe('full event sequence', () => {
it('emits agent_start → message(thought) → tool_request → tool_response → agent_end in order', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
mockExecutorInstance.run.mockImplementation(async () => {
const onActivity = MockLocalAgentExecutor.create.mock.calls[0]?.[2];
onActivity?.({
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'thinking' },
});
onActivity?.({
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'TOOL_CALL_START',
data: { callId: 'c1', name: 'tool', args: {} },
});
onActivity?.({
isSubagentActivityEvent: true,
agentName: 'TestProtocolAgent',
type: 'TOOL_CALL_END',
data: { id: 'c1', name: 'tool', output: 'result' },
});
return GOAL_OUTPUT;
});
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'go' }] },
});
await session.getResult();
const types = events.map((e) => e.type);
expect(types).toEqual([
'agent_start',
'message',
'tool_request',
'tool_response',
'agent_end',
]);
});
});
// ---------------------------------------------------------------------------
// Concurrent send() guard
// ---------------------------------------------------------------------------
describe('concurrent send() guard', () => {
it('calling send() while a stream is active throws', async () => {
let abortSignal: AbortSignal | undefined;
mockExecutorInstance.run.mockImplementation(
(_params: unknown, signal: AbortSignal) => {
abortSignal = signal;
return new Promise((_resolve, reject) => {
// Reject when aborted so getResult() can settle during cleanup
signal.addEventListener('abort', () => {
const err = new Error('AbortError');
err.name = 'AbortError';
reject(err);
});
});
},
);
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
void session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
// Wait for execution to start
await vi.waitFor(() => {
expect(abortSignal).toBeDefined();
});
// Second send() while first stream is active must throw
await expect(
session.send({
message: { content: [{ type: 'text', text: 'second' }] },
}),
).rejects.toThrow('cannot be called while a stream is active');
// Clean up: abort to unblock the hanging executor
await session.abort();
await session.getResult().catch(() => {});
});
});
// ---------------------------------------------------------------------------
// Multi-send support
// ---------------------------------------------------------------------------
describe('multi-send', () => {
it('supports sequential sends after stream completion', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
// First send
const result1 = await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
expect(result1.streamId).not.toBeNull();
const output1 = await session.getResult();
expect(output1.result).toBe('Analysis complete.');
// Second send — should work, not throw
const secondOutput = {
result: 'Second analysis.',
terminate_reason: AgentTerminateMode.GOAL,
};
mockExecutorInstance.run.mockResolvedValue(secondOutput);
const result2 = await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
expect(result2.streamId).not.toBeNull();
expect(result2.streamId).not.toBe(result1.streamId);
const output2 = await session.getResult();
expect(output2.result).toBe('Second analysis.');
});
it('getResult() returns the latest stream result', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
// First send
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
const result1 = await session.getResult();
// Second send with different output
const secondOutput = {
result: 'Different result.',
terminate_reason: AgentTerminateMode.GOAL,
};
mockExecutorInstance.run.mockResolvedValue(secondOutput);
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
const result2 = await session.getResult();
expect(result1.result).toBe('Analysis complete.');
expect(result2.result).toBe('Different result.');
});
it('buffered config does not bleed across sends', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
// Buffer config, then send first message
await session.send({ update: { config: { temperature: 0.5 } } });
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
// The executor.run params include the buffered config
const firstRunParams = mockExecutorInstance.run.mock.calls[0]?.[0];
expect(firstRunParams).toHaveProperty('temperature', 0.5);
expect(firstRunParams).toHaveProperty('query', 'first');
// Second send without buffered config — temperature should be gone
mockExecutorInstance.run.mockResolvedValue(GOAL_OUTPUT);
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
const secondRunParams = mockExecutorInstance.run.mock.calls[1]?.[0];
expect(secondRunParams).not.toHaveProperty('temperature');
expect(secondRunParams).toHaveProperty('query', 'second');
});
it('getResult() rejects when called before any send', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
await expect(session.getResult()).rejects.toThrow(
'No active or completed stream',
);
});
it('emits fresh agent_start/agent_end per stream', async () => {
const session = new LocalSubagentSession(
testDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
// First send
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
const firstStreamEvents = events.length;
expect(events[0]?.type).toBe('agent_start');
expect(events[firstStreamEvents - 1]?.type).toBe('agent_end');
// Second send
mockExecutorInstance.run.mockResolvedValue(GOAL_OUTPUT);
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
// Should have a second agent_start/agent_end pair
const secondStreamStart = events[firstStreamEvents];
const lastEvent = events[events.length - 1];
expect(secondStreamStart?.type).toBe('agent_start');
expect(lastEvent?.type).toBe('agent_end');
expect(secondStreamStart?.streamId).not.toBe(events[0]?.streamId);
});
});
});
@@ -0,0 +1,438 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview LocalSubagentProtocol wraps LocalAgentExecutor behind the
* AgentProtocol interface, translating SubagentActivityEvent callbacks into
* AgentEvents and exposing the executor result via getResult().
*
* Pattern mirrors LegacyAgentProtocol, but the loop body runs
* LocalAgentExecutor instead of GeminiClient.sendMessageStream().
*/
import { randomUUID } from 'node:crypto';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { AgentSession } from '../agent/agent-session.js';
import type {
AgentProtocol,
AgentSend,
AgentEvent,
StreamEndReason,
Unsubscribe,
ContentPart,
} from '../agent/types.js';
import { LocalAgentExecutor } from './local-executor.js';
import {
AgentTerminateMode,
type LocalAgentDefinition,
type AgentInputs,
type OutputObject,
type SubagentActivityEvent,
} from './types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isAbortLikeError(err: unknown): boolean {
return err instanceof Error && err.name === 'AbortError';
}
function mapTerminateMode(mode: AgentTerminateMode): StreamEndReason {
switch (mode) {
case AgentTerminateMode.GOAL:
return 'completed';
case AgentTerminateMode.TIMEOUT:
return 'max_time';
case AgentTerminateMode.MAX_TURNS:
return 'max_turns';
case AgentTerminateMode.ABORTED:
return 'aborted';
case AgentTerminateMode.ERROR:
case AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL:
return 'failed';
default: {
void (mode satisfies never);
return 'failed';
}
}
}
// ---------------------------------------------------------------------------
// LocalSubagentProtocol
// ---------------------------------------------------------------------------
class LocalSubagentProtocol implements AgentProtocol {
private _events: AgentEvent[] = [];
private _subscribers = new Set<(event: AgentEvent) => void>();
private _streamId: string = randomUUID();
private _eventCounter = 0;
private _agentStartEmitted = false;
private _agentEndEmitted = false;
private _activeStreamId: string | undefined;
private _abortController = new AbortController();
// Result promise wiring — re-created per stream in _beginNewStream()
private _resultResolve!: (output: OutputObject) => void;
private _resultReject!: (err: unknown) => void;
private _resultPromise: Promise<OutputObject> | undefined;
// Buffered config from send({update})
private _bufferedConfig: Record<string, unknown> = {};
constructor(
private readonly definition: LocalAgentDefinition,
private readonly context: AgentLoopContext,
// Required for API parity across protocol constructors (local, remote, legacy)
_messageBus: MessageBus,
private readonly _rawActivityCallback?: (
activity: SubagentActivityEvent,
) => void,
) {}
// ---------------------------------------------------------------------------
// AgentProtocol interface
// ---------------------------------------------------------------------------
get events(): readonly AgentEvent[] {
return this._events;
}
subscribe(callback: (event: AgentEvent) => void): Unsubscribe {
this._subscribers.add(callback);
return () => {
this._subscribers.delete(callback);
};
}
async send(payload: AgentSend): Promise<{ streamId: string | null }> {
if ('update' in payload && payload.update) {
// Buffer config for use when message send arrives
if (payload.update.config) {
this._bufferedConfig = {
...this._bufferedConfig,
...payload.update.config,
};
}
return { streamId: null };
}
if ('message' in payload && payload.message) {
if (this._activeStreamId) {
throw new Error(
'LocalSubagentProtocol.send() cannot be called while a stream is active.',
);
}
// Extract query text from the message ContentParts
const queryText = payload.message.content
.filter((p): p is ContentPart & { type: 'text' } => p.type === 'text')
.map((p) => p.text)
.join('');
// Only include 'query' in params when the message text is non-empty,
// so that callers that pass all fields via update.config are not affected.
const params: AgentInputs = {
...this._bufferedConfig,
...(queryText.length > 0 ? { query: queryText } : {}),
};
this._bufferedConfig = {};
this._beginNewStream();
const streamId = this._streamId;
// Schedule execution in a macrotask so send() resolves before agent_start
setTimeout(() => {
void this._runExecutionInBackground(params);
}, 0);
return { streamId };
}
// action and elicitations are not supported
return { streamId: null };
}
async abort(): Promise<void> {
this._abortController.abort();
}
// ---------------------------------------------------------------------------
// Protocol-specific: result access
// ---------------------------------------------------------------------------
/**
* Resolves when the executor completes, with the raw OutputObject.
* Used by LocalSubagentInvocation to build the ToolResult.
*/
getResult(): Promise<OutputObject> {
if (!this._resultPromise) {
return Promise.reject(new Error('No active or completed stream'));
}
return this._resultPromise;
}
// ---------------------------------------------------------------------------
// Core: execution
// ---------------------------------------------------------------------------
private _beginNewStream(): void {
this._streamId = randomUUID();
this._eventCounter = 0;
this._abortController = new AbortController();
this._agentStartEmitted = false;
this._agentEndEmitted = false;
this._activeStreamId = this._streamId;
this._resultPromise = new Promise<OutputObject>((resolve, reject) => {
this._resultResolve = resolve;
this._resultReject = reject;
});
}
private async _runExecutionInBackground(params: AgentInputs): Promise<void> {
this._ensureAgentStart();
try {
await this._runExecution(params);
} catch (err: unknown) {
if (this._abortController.signal.aborted || isAbortLikeError(err)) {
this._ensureAgentEnd('aborted');
// Abort resolves with an empty result — partial output is intentionally
// dropped since the caller requested cancellation.
this._resultResolve({
result: '',
terminate_reason: AgentTerminateMode.ABORTED,
});
} else {
this._emitErrorAndAgentEnd(err);
this._resultReject(err);
}
} finally {
this._clearActiveStream();
}
}
private async _runExecution(params: AgentInputs): Promise<void> {
const signal = this._abortController.signal;
const onActivity = (activity: SubagentActivityEvent): void => {
// Forward raw activity to invocation-level callback (for rich SubagentProgress display)
this._rawActivityCallback?.(activity);
this._emit(this._translateActivity(activity));
};
const executor = await LocalAgentExecutor.create(
this.definition,
this.context,
onActivity,
);
const output = await executor.run(params, signal);
if (
output.terminate_reason === AgentTerminateMode.ABORTED ||
signal.aborted
) {
this._finishStream('aborted');
} else {
this._finishStream(mapTerminateMode(output.terminate_reason));
}
this._resultResolve(output);
}
// ---------------------------------------------------------------------------
// Activity → AgentEvent translation
// ---------------------------------------------------------------------------
private _translateActivity(activity: SubagentActivityEvent): AgentEvent[] {
switch (activity.type) {
case 'THOUGHT_CHUNK': {
const rawText = activity.data['text'];
const text = String(rawText ?? '');
return [
this._makeEvent('message', {
role: 'agent',
content: [{ type: 'thought', thought: text }],
}),
];
}
case 'TOOL_CALL_START': {
const rawCallId = activity.data['callId'];
const callId = String(rawCallId ?? randomUUID());
const rawName = activity.data['name'];
const name = String(rawName ?? 'unknown');
const rawArgs = activity.data['args'];
const args: Record<string, unknown> =
rawArgs !== null &&
typeof rawArgs === 'object' &&
!Array.isArray(rawArgs)
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(rawArgs as Record<string, unknown>)
: {};
return [
this._makeEvent('tool_request', {
requestId: callId,
name,
args,
}),
];
}
case 'TOOL_CALL_END': {
const rawId = activity.data['id'];
const requestId = String(rawId ?? randomUUID());
const rawName = activity.data['name'];
const name = String(rawName ?? 'unknown');
const rawOutput = activity.data['output'];
const output = String(rawOutput ?? '');
return [
this._makeEvent('tool_response', {
requestId,
name,
content: [{ type: 'text', text: output }],
}),
];
}
case 'ERROR': {
const rawError = activity.data['error'];
const errorMsg = String(rawError ?? 'Unknown error');
return [
this._makeEvent('error', {
status: 'INTERNAL',
message: errorMsg,
fatal: false,
}),
];
}
default: {
void (activity.type satisfies never);
return [];
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers (mirrors LegacyAgentProtocol)
// ---------------------------------------------------------------------------
private _emit(events: AgentEvent[]): void {
if (events.length === 0) return;
const subscribers = [...this._subscribers];
for (const event of events) {
this._events.push(event);
if (event.type === 'agent_end') {
this._agentEndEmitted = true;
}
for (const sub of subscribers) {
sub(event);
}
}
}
private _clearActiveStream(): void {
this._activeStreamId = undefined;
}
private _ensureAgentStart(): void {
if (!this._agentStartEmitted) {
this._agentStartEmitted = true;
this._emit([this._makeEvent('agent_start', {})]);
}
}
private _ensureAgentEnd(reason: StreamEndReason = 'completed'): void {
if (!this._agentEndEmitted && this._agentStartEmitted) {
this._emit([this._makeEvent('agent_end', { reason })]);
}
}
private _finishStream(reason: StreamEndReason): void {
this._ensureAgentEnd(reason);
this._clearActiveStream();
}
private _emitErrorAndAgentEnd(err: unknown): void {
const message = err instanceof Error ? err.message : String(err);
this._ensureAgentStart();
const meta: Record<string, unknown> = {};
if (err instanceof Error) {
meta['errorName'] = err.constructor.name;
meta['stack'] = err.stack;
if ('exitCode' in err && typeof err.exitCode === 'number') {
meta['exitCode'] = err.exitCode;
}
if ('code' in err) {
meta['code'] = err.code;
}
if ('status' in err) {
meta['status'] = err.status;
}
}
this._emit([
this._makeEvent('error', {
status: 'INTERNAL',
message,
fatal: true,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}),
]);
this._ensureAgentEnd('failed');
}
private _nextEventFields() {
return {
id: `${this._streamId}-${this._eventCounter++}`,
timestamp: new Date().toISOString(),
streamId: this._streamId,
};
}
private _makeEvent<T extends AgentEvent['type']>(
type: T,
payload: Omit<AgentEvent<T>, 'id' | 'timestamp' | 'streamId' | 'type'>,
): AgentEvent {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...this._nextEventFields(),
type,
...payload,
} as AgentEvent;
}
}
// ---------------------------------------------------------------------------
// Public export
// ---------------------------------------------------------------------------
export class LocalSubagentSession extends AgentSession {
private readonly _localProtocol: LocalSubagentProtocol;
constructor(
definition: LocalAgentDefinition,
context: AgentLoopContext,
messageBus: MessageBus,
rawActivityCallback?: (activity: SubagentActivityEvent) => void,
) {
const protocol = new LocalSubagentProtocol(
definition,
context,
messageBus,
rawActivityCallback,
);
super(protocol);
this._localProtocol = protocol;
}
/**
* Returns the raw executor OutputObject once execution completes.
* Used by LocalSubagentInvocation to build the ToolResult.
*/
getResult(): Promise<OutputObject> {
return this._localProtocol.getResult();
}
}
@@ -0,0 +1,947 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { RemoteSubagentSession } from './remote-subagent-protocol.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { RemoteAgentDefinition, SubagentProgress } from './types.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { AgentEvent } from '../agent/types.js';
import type { Config } from '../config/config.js';
import type { A2AAuthProvider } from './auth-provider/types.js';
// Mock A2AClientManager at module level
vi.mock('./a2a-client-manager.js', () => ({
A2AClientManager: vi.fn().mockImplementation(() => ({
getClient: vi.fn(),
loadAgent: vi.fn(),
sendMessageStream: vi.fn(),
})),
}));
// Mock A2AAuthProviderFactory
vi.mock('./auth-provider/factory.js', () => ({
A2AAuthProviderFactory: {
create: vi.fn(),
},
}));
const mockDefinition: RemoteAgentDefinition = {
name: 'test-remote-agent',
kind: 'remote',
agentCardUrl: 'http://test-agent/card',
displayName: 'Test Remote Agent',
description: 'A test remote agent',
inputConfig: {
inputSchema: { type: 'object' },
},
};
function makeChunk(text: string) {
return {
kind: 'message' as const,
messageId: `msg-${Math.random()}`,
role: 'agent' as const,
parts: [{ kind: 'text' as const, text }],
};
}
describe('RemoteSubagentSession (protocol)', () => {
let mockClientManager: {
getClient: Mock;
loadAgent: Mock;
sendMessageStream: Mock;
};
let mockContext: AgentLoopContext;
let mockMessageBus: ReturnType<typeof createMockMessageBus>;
beforeEach(() => {
vi.clearAllMocks();
// Each test creates fresh session instances. contextId/taskId persist as
// instance fields within a session, not via static state.
mockClientManager = {
getClient: vi.fn().mockReturnValue(undefined), // client not yet loaded
loadAgent: vi.fn().mockResolvedValue(undefined),
sendMessageStream: vi.fn(),
};
const mockConfig = {
getA2AClientManager: vi.fn().mockReturnValue(mockClientManager),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
mockContext = { config: mockConfig } as unknown as AgentLoopContext;
mockMessageBus = createMockMessageBus();
// Default: sendMessageStream yields one chunk with "Hello"
mockClientManager.sendMessageStream.mockImplementation(async function* () {
yield makeChunk('Hello');
});
});
afterEach(() => {
vi.restoreAllMocks();
});
// Helper: run a session with the default or custom stream and collect events
async function runSession(
definition: RemoteAgentDefinition = mockDefinition,
query = 'test query',
) {
const session = new RemoteSubagentSession(
definition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: query }] },
});
const result = await session.getResult();
return { session, events, result };
}
// ---------------------------------------------------------------------------
// Lifecycle events
// ---------------------------------------------------------------------------
describe('lifecycle events', () => {
it('emits agent_start then agent_end(completed) on success', async () => {
const { events } = await runSession();
const types = events.map((e) => e.type);
expect(types[0]).toBe('agent_start');
expect(types[types.length - 1]).toBe('agent_end');
const end = events[events.length - 1];
if (end.type === 'agent_end') {
expect(end.reason).toBe('completed');
}
});
it('emits agent_start exactly once', async () => {
const { events } = await runSession();
expect(events.filter((e) => e.type === 'agent_start')).toHaveLength(1);
});
it('emits agent_end exactly once on error path', async () => {
mockClientManager.sendMessageStream.mockReturnValue({
[Symbol.asyncIterator]() {
return {
async next(): Promise<IteratorResult<never>> {
throw new Error('stream error');
},
};
},
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow('stream error');
expect(events.filter((e) => e.type === 'agent_end')).toHaveLength(1);
});
it('all events share the same streamId', async () => {
const { events } = await runSession();
const streamIds = new Set(events.map((e) => e.streamId));
expect(streamIds.size).toBe(1);
});
it('message returns a non-null streamId; unsupported payload returns null', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const updateResult = await session.send({
update: { config: { key: 'val' } },
});
expect(updateResult.streamId).toBeNull();
const messageResult = await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
expect(messageResult.streamId).not.toBeNull();
// complete the session to avoid dangling execution
await session.getResult();
});
});
// ---------------------------------------------------------------------------
// Chunk → AgentEvent translation
// ---------------------------------------------------------------------------
describe('chunk → AgentEvent translation', () => {
it('each A2A chunk produces a message event with incremental delta text', async () => {
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
yield makeChunk('Hello');
yield makeChunk(' world');
},
);
const { events } = await runSession();
const msgEvents = events.filter((e) => e.type === 'message');
expect(msgEvents).toHaveLength(2);
// Each message event contains only the delta, not accumulated text
if (msgEvents[0]?.type === 'message') {
const text = msgEvents[0].content.find((c) => c.type === 'text');
expect(text?.type === 'text' && text.text).toBe('Hello');
}
if (msgEvents[1]?.type === 'message') {
const text = msgEvents[1].content.find((c) => c.type === 'text');
expect(text?.type === 'text' && text.text).toBe(' world');
}
});
it('getLatestProgress() is updated per chunk with state running', async () => {
let capturedProgress: SubagentProgress | undefined;
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
yield makeChunk('Partial');
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
session.subscribe((e) => {
if (e.type === 'message') {
capturedProgress = session.getLatestProgress();
}
});
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
// During streaming, progress should be 'running'
expect(capturedProgress).toBeDefined();
// Note: by the time we check, progress may be 'completed'.
// During the message event, it was 'running'.
expect(capturedProgress?.isSubagentProgress).toBe(true);
expect(capturedProgress?.agentName).toBe('Test Remote Agent');
});
it('getLatestProgress() state is completed after getResult() resolves', async () => {
const { session } = await runSession();
const progress = session.getLatestProgress();
expect(progress?.state).toBe('completed');
expect(progress?.result).toBe('Hello');
});
});
// ---------------------------------------------------------------------------
// getResult() promise
// ---------------------------------------------------------------------------
describe('getResult()', () => {
it('resolves with ToolResult containing llmContent and SubagentProgress returnDisplay', async () => {
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
yield makeChunk('Result text');
},
);
const { result } = await runSession();
expect(result.llmContent).toEqual([{ text: 'Result text' }]);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe('completed');
expect(display.result).toBe('Result text');
expect(display.agentName).toBe('Test Remote Agent');
});
it('rejects when stream throws a non-A2A error', async () => {
mockClientManager.sendMessageStream.mockReturnValue({
[Symbol.asyncIterator]() {
return {
async next(): Promise<IteratorResult<never>> {
throw new Error('network failure');
},
};
},
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow();
});
it('resolves even with empty stream (empty final output)', async () => {
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
// yield nothing
},
);
const { result } = await runSession();
expect(result.llmContent).toEqual([{ text: '' }]);
});
});
// ---------------------------------------------------------------------------
// Session state persistence
// ---------------------------------------------------------------------------
describe('session state persistence', () => {
it('second send reuses contextId captured from first send', async () => {
let callCount = 0;
mockClientManager.sendMessageStream.mockImplementation(async function* (
_name: string,
_query: string,
opts: { contextId?: string },
) {
callCount++;
if (callCount === 1) {
yield {
kind: 'message' as const,
messageId: 'msg-1',
role: 'agent' as const,
contextId: 'ctx-from-server',
parts: [{ kind: 'text' as const, text: 'First response' }],
};
} else {
// Second send on same session should pass the contextId
expect(opts.contextId).toBe('ctx-from-server');
yield makeChunk('Second response');
}
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
// First send — establishes contextId
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
// Second send on same session — should reuse contextId
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
expect(callCount).toBe(2);
});
it('separate session instances have independent state', async () => {
const capturedContextIds: Array<string | undefined> = [];
mockClientManager.sendMessageStream.mockImplementation(async function* (
_name: string,
_query: string,
opts: { contextId?: string },
) {
capturedContextIds.push(opts.contextId);
yield {
kind: 'message' as const,
messageId: 'msg-1',
role: 'agent' as const,
contextId: 'ctx-from-server',
parts: [{ kind: 'text' as const, text: 'ok' }],
};
});
// Two separate sessions for the same agent — state is NOT shared
const session1 = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session1.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session1.getResult();
const session2 = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session2.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session2.getResult();
// Both start with no contextId — separate instances, no shared state
expect(capturedContextIds[0]).toBeUndefined();
expect(capturedContextIds[1]).toBeUndefined();
});
it('taskId is cleared when a terminal-state task chunk is received', async () => {
let callCount = 0;
const capturedTaskIds: Array<string | undefined> = [];
mockClientManager.sendMessageStream.mockImplementation(async function* (
_n: string,
_q: string,
opts: { taskId?: string },
) {
callCount++;
capturedTaskIds.push(opts.taskId);
if (callCount === 1) {
yield {
kind: 'task' as const,
id: 'task-123',
contextId: 'ctx-1',
status: { state: 'completed' as const },
};
} else {
yield makeChunk('done');
}
});
// Use same session for multi-send
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
expect(callCount).toBe(2);
// First call starts with no taskId
expect(capturedTaskIds[0]).toBeUndefined();
// Second call: taskId was cleared because terminal-state task chunk was received
expect(capturedTaskIds[1]).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Auth setup
// ---------------------------------------------------------------------------
describe('auth setup', () => {
it('no auth → loadAgent called without auth handler', async () => {
await runSession();
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'test-remote-agent',
{ type: 'url', url: 'http://test-agent/card' },
undefined,
);
});
it('definition.auth present → A2AAuthProviderFactory.create called', async () => {
const authDef: RemoteAgentDefinition = {
...mockDefinition,
name: 'auth-agent',
auth: {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'secret',
},
};
const mockProvider = {
type: 'http' as const,
headers: vi.fn().mockResolvedValue({ Authorization: 'Bearer secret' }),
shouldRetryWithHeaders: vi.fn(),
} as unknown as A2AAuthProvider;
(A2AAuthProviderFactory.create as Mock).mockResolvedValue(mockProvider);
await runSession(authDef, 'q');
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith(
expect.objectContaining({
agentName: 'auth-agent',
agentCardUrl: 'http://test-agent/card',
}),
);
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
'auth-agent',
expect.any(Object),
mockProvider,
);
});
it('auth factory returns undefined → throws error that rejects getResult()', async () => {
const authDef: RemoteAgentDefinition = {
...mockDefinition,
name: 'failing-auth-agent',
auth: {
type: 'http' as const,
scheme: 'Bearer' as const,
token: 'secret',
},
};
(A2AAuthProviderFactory.create as Mock).mockResolvedValue(undefined);
const session = new RemoteSubagentSession(
authDef,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow(
"Failed to create auth provider for agent 'failing-auth-agent'",
);
});
it('agent already loaded → loadAgent not called again', async () => {
// Return a client object (truthy) so getClient returns defined
mockClientManager.getClient.mockReturnValue({});
await runSession();
expect(mockClientManager.loadAgent).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
describe('error handling', () => {
it('stream error → error event + agent_end(failed)', async () => {
mockClientManager.sendMessageStream.mockReturnValue({
[Symbol.asyncIterator]() {
return {
async next(): Promise<IteratorResult<never>> {
throw new Error('network error');
},
};
},
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow();
const errEvent = events.find((e) => e.type === 'error');
expect(errEvent).toBeDefined();
const endEvent = events.find((e) => e.type === 'agent_end');
expect(endEvent).toBeDefined();
if (endEvent?.type === 'agent_end') {
expect(endEvent.reason).toBe('failed');
}
});
it('missing A2AClientManager → rejects getResult()', async () => {
const mockConfig = {
getA2AClientManager: vi.fn().mockReturnValue(undefined),
injectionService: {
getLatestInjectionIndex: vi.fn().mockReturnValue(0),
},
} as unknown as Config;
const noClientContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const session = new RemoteSubagentSession(
mockDefinition,
noClientContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await expect(session.getResult()).rejects.toThrow(
'A2AClientManager not available',
);
});
});
// ---------------------------------------------------------------------------
// Subscription
// ---------------------------------------------------------------------------
describe('subscription', () => {
it('unsubscribe stops event delivery', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const received: AgentEvent[] = [];
const unsub = session.subscribe((e) => received.push(e));
unsub();
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(received).toHaveLength(0);
});
it('multiple subscribers all receive events', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events1: AgentEvent[] = [];
const events2: AgentEvent[] = [];
session.subscribe((e) => events1.push(e));
session.subscribe((e) => events2.push(e));
await session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
await session.getResult();
expect(events1.length).toBeGreaterThan(0);
expect(events1).toEqual(events2);
});
});
// ---------------------------------------------------------------------------
// Abort
// ---------------------------------------------------------------------------
describe('abort()', () => {
it('abort() causes agent_end(reason:aborted)', async () => {
let rejectWithAbort: ((err: Error) => void) | undefined;
// Stream that blocks until aborted, then throws AbortError
mockClientManager.sendMessageStream.mockImplementation(
// eslint-disable-next-line require-yield
async function* () {
await new Promise<void>((_resolve, reject) => {
rejectWithAbort = reject;
});
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
void session.send({
message: { content: [{ type: 'text', text: 'q' }] },
});
// Wait for agent_start to be emitted before aborting
await vi.waitFor(() => {
expect(events.some((e) => e.type === 'agent_start')).toBe(true);
});
await session.abort();
// Simulate the transport throwing AbortError when signal fires
const abortErr = new Error('AbortError');
abortErr.name = 'AbortError';
rejectWithAbort?.(abortErr);
const result = await session.getResult();
expect(result.llmContent).toEqual([{ text: '' }]);
expect(result.returnDisplay).toBe('');
const endEvent = events.find((e) => e.type === 'agent_end');
expect(endEvent).toBeDefined();
if (endEvent?.type === 'agent_end') {
expect(endEvent.reason).toBe('aborted');
}
});
});
// ---------------------------------------------------------------------------
// sendMessageStream call args
// ---------------------------------------------------------------------------
describe('sendMessageStream call arguments', () => {
it('passes the query string from the message payload', async () => {
await runSession(mockDefinition, 'my specific query');
expect(mockClientManager.sendMessageStream).toHaveBeenCalledWith(
'test-remote-agent',
'my specific query',
expect.objectContaining({ signal: expect.any(Object) }),
);
});
it('uses DEFAULT_QUERY_STRING when message text is empty', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: '' }] },
});
await session.getResult();
// DEFAULT_QUERY_STRING = 'Get Started!'
expect(mockClientManager.sendMessageStream).toHaveBeenCalledWith(
'test-remote-agent',
'Get Started!',
expect.objectContaining({ signal: expect.any(Object) }),
);
});
});
// ---------------------------------------------------------------------------
// Concurrent send() guard
// ---------------------------------------------------------------------------
describe('concurrent send() guard', () => {
it('calling send() while a stream is active throws', async () => {
let resolveChunk!: () => void;
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
// Block until test releases the chunk
await new Promise<void>((resolve) => {
resolveChunk = resolve;
});
yield makeChunk('late');
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
void session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
// Wait for the stream to actually start (agent_start emitted)
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
await vi.waitFor(() => {
expect(events.some((e) => e.type === 'agent_start')).toBe(true);
});
// Second send() while first stream is active must throw
await expect(
session.send({
message: { content: [{ type: 'text', text: 'second' }] },
}),
).rejects.toThrow('cannot be called while a stream is active');
// Clean up: release the blocked generator so getResult() can settle
resolveChunk();
await session.getResult().catch(() => {});
});
});
// ---------------------------------------------------------------------------
// Multi-send support
// ---------------------------------------------------------------------------
describe('multi-send', () => {
it('supports sequential sends after stream completion', async () => {
let callCount = 0;
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
callCount++;
yield makeChunk(`Response ${callCount}`);
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
// First send
const result1 = await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
expect(result1.streamId).not.toBeNull();
const output1 = await session.getResult();
expect(output1.llmContent).toEqual([{ text: 'Response 1' }]);
// Second send — should work, not throw
const result2 = await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
expect(result2.streamId).not.toBeNull();
expect(result2.streamId).not.toBe(result1.streamId);
const output2 = await session.getResult();
expect(output2.llmContent).toEqual([{ text: 'Response 2' }]);
});
it('getResult() returns the latest stream result', async () => {
let callCount = 0;
mockClientManager.sendMessageStream.mockImplementation(
async function* () {
callCount++;
yield makeChunk(`Result ${callCount}`);
},
);
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
const result1 = await session.getResult();
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
const result2 = await session.getResult();
expect(result1.llmContent).toEqual([{ text: 'Result 1' }]);
expect(result2.llmContent).toEqual([{ text: 'Result 2' }]);
});
it('contextId/taskId persist across sends within the same session', async () => {
let sendCallCount = 0;
mockClientManager.sendMessageStream.mockImplementation(async function* (
_name: string,
_query: string,
_opts: Record<string, unknown>,
) {
sendCallCount++;
// First call returns ids; second call should receive them
yield {
kind: 'message' as const,
messageId: `msg-${sendCallCount}`,
contextId: `ctx-${sendCallCount}`,
taskId: `task-${sendCallCount}`,
role: 'agent' as const,
parts: [{ kind: 'text' as const, text: `Response ${sendCallCount}` }],
};
});
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
// First send — establishes contextId/taskId
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
// Second send — should pass the persisted contextId/taskId
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
// Verify the second call received the contextId/taskId from first call
expect(mockClientManager.sendMessageStream).toHaveBeenCalledTimes(2);
const secondCallOpts =
mockClientManager.sendMessageStream.mock.calls[1]?.[2];
expect(secondCallOpts).toHaveProperty('contextId', 'ctx-1');
expect(secondCallOpts).toHaveProperty('taskId', 'task-1');
});
it('getResult() rejects when called before any send', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
await expect(session.getResult()).rejects.toThrow(
'No active or completed stream',
);
});
it('emits fresh agent_start/agent_end per stream', async () => {
const session = new RemoteSubagentSession(
mockDefinition,
mockContext,
mockMessageBus,
);
const events: AgentEvent[] = [];
session.subscribe((e) => events.push(e));
// First send
await session.send({
message: { content: [{ type: 'text', text: 'first' }] },
});
await session.getResult();
const firstStreamEvents = events.length;
expect(events[0]?.type).toBe('agent_start');
expect(events[firstStreamEvents - 1]?.type).toBe('agent_end');
// Second send
await session.send({
message: { content: [{ type: 'text', text: 'second' }] },
});
await session.getResult();
// Should have a second agent_start/agent_end pair
const secondStreamStart = events[firstStreamEvents];
const lastEvent = events[events.length - 1];
expect(secondStreamStart?.type).toBe('agent_start');
expect(lastEvent?.type).toBe('agent_end');
expect(secondStreamStart?.streamId).not.toBe(events[0]?.streamId);
});
});
});
@@ -0,0 +1,431 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview RemoteSubagentProtocol wraps A2A remote agent streaming
* behind the AgentProtocol interface.
*
* Pattern mirrors LocalSubagentProtocol and LegacyAgentProtocol, but the loop
* body drives A2AClientManager instead of LocalAgentExecutor.
*/
import { randomUUID } from 'node:crypto';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { AgentSession } from '../agent/agent-session.js';
import type {
AgentProtocol,
AgentSend,
AgentEvent,
StreamEndReason,
Unsubscribe,
ContentPart,
} from '../agent/types.js';
import type { ToolResult } from '../tools/tools.js';
import {
DEFAULT_QUERY_STRING,
type RemoteAgentDefinition,
type SubagentProgress,
getRemoteAgentTargetUrl,
getAgentCardLoadOptions,
} from './types.js';
import { A2AResultReassembler, extractIdsFromResponse } from './a2aUtils.js';
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import { A2AAgentError } from './a2a-errors.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isAbortLikeError(err: unknown): boolean {
return err instanceof Error && err.name === 'AbortError';
}
// ---------------------------------------------------------------------------
// RemoteSubagentProtocol
// ---------------------------------------------------------------------------
class RemoteSubagentProtocol implements AgentProtocol {
private _events: AgentEvent[] = [];
private _subscribers = new Set<(event: AgentEvent) => void>();
private _streamId: string = randomUUID();
private _eventCounter = 0;
private _agentStartEmitted = false;
private _agentEndEmitted = false;
private _activeStreamId: string | undefined;
private _abortController = new AbortController();
// A2A conversation state — persists across sends within this session instance
private contextId: string | undefined;
private taskId: string | undefined;
private authHandler: AuthenticationHandler | undefined;
// Agent display name (for SubagentProgress construction)
private readonly _agentName: string;
// Latest SubagentProgress — updated per chunk, used for error recovery
private _latestProgress: SubagentProgress | undefined;
// Result promise wiring — re-created per stream in _beginNewStream()
private _resultResolve!: (result: ToolResult) => void;
private _resultReject!: (err: unknown) => void;
private _resultPromise: Promise<ToolResult> | undefined;
constructor(
private readonly definition: RemoteAgentDefinition,
private readonly context: AgentLoopContext,
// Required for API parity across protocol constructors (local, remote, legacy)
_messageBus: MessageBus,
) {
this._agentName = definition.displayName ?? definition.name;
}
// ---------------------------------------------------------------------------
// AgentProtocol interface
// ---------------------------------------------------------------------------
get events(): readonly AgentEvent[] {
return this._events;
}
subscribe(callback: (event: AgentEvent) => void): Unsubscribe {
this._subscribers.add(callback);
return () => {
this._subscribers.delete(callback);
};
}
async send(payload: AgentSend): Promise<{ streamId: string | null }> {
if ('message' in payload && payload.message) {
if (this._activeStreamId) {
throw new Error(
'RemoteSubagentProtocol.send() cannot be called while a stream is active.',
);
}
const query =
payload.message.content
.filter((p): p is ContentPart & { type: 'text' } => p.type === 'text')
.map((p) => p.text)
.join('') || DEFAULT_QUERY_STRING;
this._beginNewStream();
const streamId = this._streamId;
setTimeout(() => {
void this._runStreamInBackground(query);
}, 0);
return { streamId };
}
// update/action/elicitations not used for remote agents
return { streamId: null };
}
async abort(): Promise<void> {
this._abortController.abort();
}
// ---------------------------------------------------------------------------
// Protocol-specific: result access
// ---------------------------------------------------------------------------
getResult(): Promise<ToolResult> {
if (!this._resultPromise) {
return Promise.reject(new Error('No active or completed stream'));
}
return this._resultPromise;
}
getLatestProgress(): SubagentProgress | undefined {
return this._latestProgress;
}
// ---------------------------------------------------------------------------
// Core: A2A streaming
// ---------------------------------------------------------------------------
private _beginNewStream(): void {
this._streamId = randomUUID();
this._eventCounter = 0;
this._abortController = new AbortController();
this._agentStartEmitted = false;
this._agentEndEmitted = false;
this._activeStreamId = this._streamId;
this._resultPromise = new Promise<ToolResult>((resolve, reject) => {
this._resultResolve = resolve;
this._resultReject = reject;
});
}
private async _runStreamInBackground(query: string): Promise<void> {
this._ensureAgentStart();
try {
await this._runStream(query);
} catch (err: unknown) {
if (this._abortController.signal.aborted || isAbortLikeError(err)) {
this._ensureAgentEnd('aborted');
// Abort resolves with an empty result — partial output is intentionally
// dropped since the caller requested cancellation.
this._resultResolve({
llmContent: [{ text: '' }],
returnDisplay: '',
});
} else {
this._emitErrorAndAgentEnd(err);
this._resultReject(err);
}
} finally {
this._clearActiveStream();
}
}
private async _runStream(query: string): Promise<void> {
const clientManager = this.context.config.getA2AClientManager();
if (!clientManager) {
throw new Error(
`RemoteSubagentProtocol: A2AClientManager not available for '${this.definition.name}'.`,
);
}
const authHandler = await this._getAuthHandler();
if (!clientManager.getClient(this.definition.name)) {
await clientManager.loadAgent(
this.definition.name,
getAgentCardLoadOptions(this.definition),
authHandler,
);
}
const reassembler = new A2AResultReassembler();
let prevText = '';
const stream = clientManager.sendMessageStream(
this.definition.name,
query,
{
contextId: this.contextId,
taskId: this.taskId,
signal: this._abortController.signal,
},
);
for await (const chunk of stream) {
reassembler.update(chunk);
const {
contextId: newContextId,
taskId: newTaskId,
clearTaskId,
} = extractIdsFromResponse(chunk);
if (newContextId) this.contextId = newContextId;
this.taskId = clearTaskId ? undefined : (newTaskId ?? this.taskId);
const currentText = reassembler.toString();
// Update latest progress snapshot (for invocation's error recovery)
this._latestProgress = {
isSubagentProgress: true,
agentName: this._agentName,
state: 'running',
recentActivity: reassembler.toActivityItems(),
result: currentText,
};
// Emit delta as a message event
const delta = currentText.slice(prevText.length);
if (delta) {
this._emit([
this._makeEvent('message', {
role: 'agent',
content: [{ type: 'text', text: delta }],
}),
]);
prevText = currentText;
}
}
const finalOutput = reassembler.toString();
debugLogger.debug(
`[RemoteSubagentProtocol] ${this.definition.name} finished, output length: ${finalOutput.length}`,
);
const finalProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this._agentName,
state: 'completed',
result: finalOutput,
recentActivity: reassembler.toActivityItems(),
};
this._latestProgress = finalProgress;
this._finishStream('completed');
this._resultResolve({
llmContent: [{ text: finalOutput }],
returnDisplay: finalProgress,
});
}
private async _getAuthHandler(): Promise<AuthenticationHandler | undefined> {
if (this.authHandler) return this.authHandler;
if (!this.definition.auth) return undefined;
const targetUrl = getRemoteAgentTargetUrl(this.definition);
const provider = await A2AAuthProviderFactory.create({
authConfig: this.definition.auth,
agentName: this.definition.name,
targetUrl,
agentCardUrl: this.definition.agentCardUrl,
});
if (!provider) {
throw new Error(
`Failed to create auth provider for agent '${this.definition.name}'`,
);
}
this.authHandler = provider;
return this.authHandler;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
private _emit(events: AgentEvent[]): void {
if (events.length === 0) return;
const subscribers = [...this._subscribers];
for (const event of events) {
this._events.push(event);
if (event.type === 'agent_end') {
this._agentEndEmitted = true;
}
for (const sub of subscribers) {
sub(event);
}
}
}
private _clearActiveStream(): void {
this._activeStreamId = undefined;
}
private _ensureAgentStart(): void {
if (!this._agentStartEmitted) {
this._agentStartEmitted = true;
this._emit([this._makeEvent('agent_start', {})]);
}
}
private _ensureAgentEnd(reason: StreamEndReason = 'completed'): void {
if (!this._agentEndEmitted && this._agentStartEmitted) {
this._emit([this._makeEvent('agent_end', { reason })]);
}
}
private _finishStream(reason: StreamEndReason): void {
this._ensureAgentEnd(reason);
this._clearActiveStream();
}
private _emitErrorAndAgentEnd(err: unknown): void {
const message = this._formatError(err);
this._ensureAgentStart();
const meta: Record<string, unknown> = {};
if (err instanceof Error) {
meta['errorName'] = err.constructor.name;
meta['stack'] = err.stack;
}
this._emit([
this._makeEvent('error', {
status: 'INTERNAL',
message,
fatal: true,
...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
}),
]);
this._ensureAgentEnd('failed');
}
private _formatError(error: unknown): string {
if (error instanceof A2AAgentError) {
return error.userMessage;
}
return `Error calling remote agent: ${error instanceof Error ? error.message : String(error)}`;
}
private _nextEventFields() {
return {
id: `${this._streamId}-${this._eventCounter++}`,
timestamp: new Date().toISOString(),
streamId: this._streamId,
};
}
private _makeEvent<T extends AgentEvent['type']>(
type: T,
payload: Omit<AgentEvent<T>, 'id' | 'timestamp' | 'streamId' | 'type'>,
): AgentEvent {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
...this._nextEventFields(),
type,
...payload,
} as AgentEvent;
}
}
// ---------------------------------------------------------------------------
// Public export
// ---------------------------------------------------------------------------
export class RemoteSubagentSession extends AgentSession {
private readonly _remoteProtocol: RemoteSubagentProtocol;
constructor(
definition: RemoteAgentDefinition,
context: AgentLoopContext,
messageBus: MessageBus,
) {
const protocol = new RemoteSubagentProtocol(
definition,
context,
messageBus,
);
super(protocol);
this._remoteProtocol = protocol;
}
/**
* Returns the ToolResult once the remote agent stream completes.
* Used by RemoteAgentInvocation to return the result.
*/
getResult(): Promise<ToolResult> {
return this._remoteProtocol.getResult();
}
/**
* Returns the most recent SubagentProgress snapshot, updated per streaming
* chunk. Useful for constructing error progress when getResult() rejects.
*/
getLatestProgress(): SubagentProgress | undefined {
return this._remoteProtocol.getLatestProgress();
}
/**
* Convenience: start execution with a query string.
* Equivalent to send({message: {content: [{type:'text', text: query}]}}).
*/
async startWithQuery(query: string): Promise<{ streamId: string | null }> {
return this.send({
message: { content: [{ type: 'text', text: query }] },
});
}
}
@@ -59,7 +59,7 @@ describe('Auto Routing Fallback Integration', () => {
return ''; // Fallback for other files
});
fakeGenerator = new FakeContentGenerator([]);
fakeGenerator = new FakeContentGenerator([], []);
});
afterEach(() => {
+8
View File
@@ -346,6 +346,14 @@ export class CodeAssistServer implements ContentGenerator {
throw Error();
}
async createCachedContent(): Promise<never> {
throw new Error('Explicit caching is not supported for Code Assist auth.');
}
async updateCachedContent(): Promise<never> {
throw new Error('Explicit caching is not supported for Code Assist auth.');
}
async listExperiments(
metadata: ClientMetadata,
): Promise<ListExperimentsResponse> {
+12 -363
View File
@@ -13,25 +13,30 @@ import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { flattenMemory } from '../config/memory.js';
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
import {
getGlobalMemoryFilePath,
PROJECT_MEMORY_INDEX_FILENAME,
} from '../tools/memoryTool.js';
import { isSubpath } from '../utils/paths.js';
import {
type AppliedSkillPatchTarget,
type InboxMemoryPatchKind,
applyParsedPatchesWithAllowedRoots,
applyParsedSkillPatches,
canonicalizeAllowedPatchRoots,
findDisallowedMemoryPatchTarget,
getInboxMemoryPatchSourcePath,
getMemoryPatchTargetValidationContext,
isResolvedMemoryPatchTargetAllowed,
hasParsedPatchHunks,
isProjectSkillPatchTarget,
resolveTargetWithinAllowedRoots,
listInboxPatchFiles,
listValidInboxPatchFiles,
normalizeInboxMemoryPatchPath,
resolveMemoryPatchTargetWithinAllowedSet,
validateParsedSkillPatchHeaders,
} from '../services/memoryPatchUtils.js';
import { readExtractionState } from '../services/memoryService.js';
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import type { MessageActionReturn, ToolActionReturn } from './types.js';
export type { InboxMemoryPatchKind } from '../services/memoryPatchUtils.js';
export { getAllowedMemoryPatchRoots } from '../services/memoryPatchUtils.js';
export function showMemory(config: Config): MessageActionReturn {
const memoryContent = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
@@ -346,8 +351,6 @@ export interface InboxPatch {
extractedAt?: string;
}
export type InboxMemoryPatchKind = 'private' | 'global';
/**
* One target file inside a memory patch (most patches will have a single entry).
*/
@@ -420,236 +423,6 @@ function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function getMemoryPatchRoot(
memoryDir: string,
kind: InboxMemoryPatchKind,
): string {
return path.join(memoryDir, '.inbox', kind);
}
function isSubpathOrSame(childPath: string, parentPath: string): boolean {
return isSubpath(parentPath, childPath);
}
function normalizeInboxMemoryPatchPath(
relativePath: string,
): string | undefined {
if (
relativePath.length === 0 ||
path.isAbsolute(relativePath) ||
relativePath.includes('\\')
) {
return undefined;
}
const normalizedPath = path.posix.normalize(relativePath);
if (
normalizedPath === '.' ||
normalizedPath.startsWith('../') ||
normalizedPath === '..' ||
!normalizedPath.endsWith('.patch')
) {
return undefined;
}
return normalizedPath;
}
/**
* Returns coarse directory roots (or single-file roots) used for canonical
* containment checks before the kind-specific target validator runs.
*
* - `private` is rooted at the project memory directory, then narrowed to
* direct memory markdown documents by `isAllowedPrivateMemoryDocumentPath`.
* - `global` is intentionally a single-file allowlist: the only writeable
* global file is the personal `~/.gemini/GEMINI.md`. Other files under
* `~/.gemini/` (settings, credentials, oauth, keybindings, etc.) are off-limits.
*/
export function getAllowedMemoryPatchRoots(
config: Config,
kind: InboxMemoryPatchKind,
): string[] {
switch (kind) {
case 'private':
return [path.resolve(config.storage.getProjectMemoryTempDir())];
case 'global':
return [path.resolve(getGlobalMemoryFilePath())];
default:
throw new Error(`Unknown memory patch kind: ${kind as string}`);
}
}
interface MemoryPatchTargetValidationContext {
kind: InboxMemoryPatchKind;
allowedRoots: string[];
privateMemoryDirs: string[];
globalMemoryFiles: string[];
}
function hasMarkdownExtension(fileName: string): boolean {
return fileName.toLowerCase().endsWith('.md');
}
function isAllowedPrivateMemoryFileName(fileName: string): boolean {
if (fileName === PROJECT_MEMORY_INDEX_FILENAME) {
return true;
}
return !fileName.startsWith('.') && hasMarkdownExtension(fileName);
}
function uniqueResolvedPaths(paths: readonly string[]): string[] {
return Array.from(new Set(paths.map((filePath) => path.resolve(filePath))));
}
function isSamePath(leftPath: string, rightPath: string): boolean {
return isSubpath(leftPath, rightPath) && isSubpath(rightPath, leftPath);
}
function includesSamePath(
paths: readonly string[],
targetPath: string,
): boolean {
return paths.some((candidate) => isSamePath(candidate, targetPath));
}
function isAllowedPrivateMemoryDocumentPath(
targetPath: string,
memoryDirs: readonly string[],
): boolean {
const resolvedTargetPath = path.resolve(targetPath);
const targetDir = path.dirname(resolvedTargetPath);
if (!includesSamePath(memoryDirs, targetDir)) {
return false;
}
return isAllowedPrivateMemoryFileName(path.basename(resolvedTargetPath));
}
function isAllowedGlobalMemoryDocumentPath(
targetPath: string,
globalMemoryFiles: readonly string[],
): boolean {
const resolvedTargetPath = path.resolve(targetPath);
return includesSamePath(globalMemoryFiles, resolvedTargetPath);
}
async function getMemoryPatchTargetValidationContext(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<MemoryPatchTargetValidationContext> {
const allowedRoots = await canonicalizeAllowedPatchRoots(
getAllowedMemoryPatchRoots(config, kind),
);
if (kind === 'global') {
const rawGlobalMemoryFile = path.resolve(getGlobalMemoryFilePath());
const canonicalGlobalMemoryFiles = await canonicalizeAllowedPatchRoots([
rawGlobalMemoryFile,
]);
return {
kind,
allowedRoots,
privateMemoryDirs: [],
globalMemoryFiles: uniqueResolvedPaths([
rawGlobalMemoryFile,
...canonicalGlobalMemoryFiles,
]),
};
}
const rawPrivateMemoryDir = path.resolve(
config.storage.getProjectMemoryTempDir(),
);
const canonicalPrivateMemoryDirs = await canonicalizeAllowedPatchRoots([
rawPrivateMemoryDir,
]);
const privateMemoryDirs = uniqueResolvedPaths([
rawPrivateMemoryDir,
...canonicalPrivateMemoryDirs,
]);
return { kind, allowedRoots, privateMemoryDirs, globalMemoryFiles: [] };
}
function isResolvedMemoryPatchTargetAllowed(
resolvedTargetPath: string,
context: MemoryPatchTargetValidationContext,
): boolean {
if (context.kind === 'global') {
return isAllowedGlobalMemoryDocumentPath(
resolvedTargetPath,
context.globalMemoryFiles,
);
}
if (context.kind === 'private') {
return isAllowedPrivateMemoryDocumentPath(
resolvedTargetPath,
context.privateMemoryDirs,
);
}
return true;
}
async function resolveMemoryPatchTargetWithinAllowedSet(
targetPath: string,
context: MemoryPatchTargetValidationContext,
): Promise<string | undefined> {
const resolvedTargetPath = await resolveTargetWithinAllowedRoots(
targetPath,
context.allowedRoots,
);
if (!resolvedTargetPath) {
return undefined;
}
if (
context.kind === 'private' &&
(!isAllowedPrivateMemoryDocumentPath(
targetPath,
context.privateMemoryDirs,
) ||
!isAllowedPrivateMemoryDocumentPath(
resolvedTargetPath,
context.privateMemoryDirs,
))
) {
return undefined;
}
if (
context.kind === 'global' &&
(!isAllowedGlobalMemoryDocumentPath(
targetPath,
context.globalMemoryFiles,
) ||
!isAllowedGlobalMemoryDocumentPath(
resolvedTargetPath,
context.globalMemoryFiles,
))
) {
return undefined;
}
return resolvedTargetPath;
}
async function findDisallowedMemoryPatchTarget(
parsedPatches: Diff.StructuredPatch[],
context: MemoryPatchTargetValidationContext,
): Promise<string | undefined> {
const validated = validateParsedSkillPatchHeaders(parsedPatches);
if (!validated.success) {
return undefined;
}
for (const header of validated.patches) {
if (
!(await resolveMemoryPatchTargetWithinAllowedSet(
header.targetPath,
context,
))
) {
return header.targetPath;
}
}
return undefined;
}
async function getFileMtimeIso(filePath: string): Promise<string | undefined> {
try {
const stats = await fs.stat(filePath);
@@ -659,26 +432,6 @@ async function getFileMtimeIso(filePath: string): Promise<string | undefined> {
}
}
async function getInboxMemoryPatchSourcePath(
config: Config,
kind: InboxMemoryPatchKind,
relativePath: string,
): Promise<string | undefined> {
const normalizedPath = normalizeInboxMemoryPatchPath(relativePath);
if (!normalizedPath) {
return undefined;
}
const patchRoot = path.resolve(
getMemoryPatchRoot(config.storage.getProjectMemoryTempDir(), kind),
);
const sourcePath = path.resolve(patchRoot, ...normalizedPath.split('/'));
if (!isSubpathOrSame(sourcePath, patchRoot)) {
return undefined;
}
return sourcePath;
}
async function patchTargetsProjectSkills(
targetPaths: string[],
config: Config,
@@ -713,110 +466,6 @@ function formatMemoryKindLabel(kind: InboxMemoryPatchKind): string {
}
}
/**
* Returns the absolute paths of every `.patch` file currently in the kind's
* inbox directory (sorted by basename for stable ordering at apply time).
*
* NOTE: this is a raw filesystem listing it does NOT validate patch shape
* or that targets fall inside the kind's allowed root. Callers that need
* "what the user actually sees in the inbox" should use `listValidInboxPatchFiles`.
*/
async function listInboxPatchFiles(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<string[]> {
const patchRoot = getMemoryPatchRoot(
config.storage.getProjectMemoryTempDir(),
kind,
);
const found: string[] = [];
async function walk(currentDir: string): Promise<void> {
let dirEntries: Array<import('node:fs').Dirent>;
try {
dirEntries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of dirEntries) {
const entryPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
await walk(entryPath);
continue;
}
if (entry.isFile() && entry.name.endsWith('.patch')) {
found.push(entryPath);
}
}
}
await walk(patchRoot);
return found.sort();
}
/**
* Returns only the inbox patch files that pass the same validation as the
* inbox listing (parseable, has hunks, valid headers, targets in the kind's
* allowed target set). Used by aggregate apply so the user only ever sees
* results for patches the inbox actually surfaced.
*/
async function listValidInboxPatchFiles(
config: Config,
kind: InboxMemoryPatchKind,
): Promise<string[]> {
const patchFiles = await listInboxPatchFiles(config, kind);
if (patchFiles.length === 0) {
return [];
}
const validationContext = await getMemoryPatchTargetValidationContext(
config,
kind,
);
const valid: string[] = [];
for (const sourcePath of patchFiles) {
let content: string;
try {
content = await fs.readFile(sourcePath, 'utf-8');
} catch {
continue;
}
let parsed: Diff.StructuredPatch[];
try {
parsed = Diff.parsePatch(content);
} catch {
continue;
}
if (!hasParsedPatchHunks(parsed)) {
continue;
}
const validated = validateParsedSkillPatchHeaders(parsed);
if (!validated.success) {
continue;
}
const targetsAllAllowed = await Promise.all(
validated.patches.map(
async (header) =>
(await resolveMemoryPatchTargetWithinAllowedSet(
header.targetPath,
validationContext,
)) !== undefined,
),
);
if (!targetsAllAllowed.every(Boolean)) {
continue;
}
valid.push(sourcePath);
}
return valid;
}
/**
* Scans `<memoryDir>/.inbox/{private,global}/` and returns ONE consolidated
* inbox entry per kind. Each entry aggregates all hunks from every valid
+15 -1
View File
@@ -14,6 +14,7 @@ import type { ConversationRecord } from '../services/chatRecordingService.js';
import type {
AgentHistoryProviderConfig,
ContextManagementConfig,
ContextCachingConfig,
ToolOutputMaskingConfig,
} from '../context/types.js';
export type { ConversationRecord };
@@ -717,6 +718,7 @@ export interface ConfigParameters {
experimentalAutoMemory?: boolean;
experimentalGemma?: boolean;
experimentalContextManagementConfig?: string;
experimentalContextCaching?: Partial<ContextCachingConfig>;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
@@ -972,6 +974,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly modelSteering: boolean;
private memoryContextManager?: MemoryContextManager;
private readonly contextManagement: ContextManagementConfig;
private readonly contextCaching: ContextCachingConfig;
private terminalBackground: string | undefined = undefined;
private remoteAdminSettings: AdminControlsSettings | undefined;
private latestApiRequest: GenerateContentParameters | undefined;
@@ -1224,6 +1227,13 @@ export class Config implements McpContext, AgentLoopContext {
},
},
};
this.contextCaching = {
enabled: params.experimentalContextCaching?.enabled ?? false,
thresholdTokens:
params.experimentalContextCaching?.thresholdTokens ?? 32768,
ttlMinutes: params.experimentalContextCaching?.ttlMinutes ?? 60,
autoRenew: params.experimentalContextCaching?.autoRenew ?? true,
};
this.topicUpdateNarration = params.topicUpdateNarration ?? true;
this.modelSteering = params.modelSteering ?? false;
this.injectionService = new InjectionService(() =>
@@ -2574,7 +2584,11 @@ export class Config implements McpContext, AgentLoopContext {
return this.contextManagement;
}
get agentHistoryProviderConfig(): AgentHistoryProviderConfig {
getContextCachingConfig(): ContextCachingConfig {
return this.contextCaching;
}
getAgentHistoryProviderConfig(): AgentHistoryProviderConfig {
return {
maxTokens: this.contextManagement.historyWindow.maxTokens,
retainedTokens: this.contextManagement.historyWindow.retainedTokens,
@@ -221,6 +221,19 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
extends: 'gemini-3-flash-base',
modelConfig: {},
},
'context-snapshotter': {
extends: 'gemini-3-flash-base',
modelConfig: {
generateContentConfig: {
thinkingConfig: {
thinkingLevel: ThinkingLevel.HIGH,
},
temperature: 1,
topP: 0.95,
topK: 64,
},
},
},
'chat-compression-3-pro': {
modelConfig: {
model: 'gemini-3-pro-preview',
+7
View File
@@ -87,6 +87,13 @@ export class Storage {
return path.join(Storage.getGlobalGeminiDir(), GOOGLE_ACCOUNTS_FILENAME);
}
static getContextCacheMetadataPath(): string {
return path.join(
Storage.getGlobalGeminiDir(),
'context-cache-metadata.json',
);
}
static getTrustedFoldersPath(): string {
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
@@ -139,6 +139,8 @@ export const generalistProfile: ContextProfile = {
env,
resolveProcessorOptions(config, 'StateSnapshotSync', {
target: 'max',
maxStateTokens: 4000,
maxSummaryTurns: 5,
}),
),
],
@@ -157,6 +159,8 @@ export const generalistProfile: ContextProfile = {
env,
resolveProcessorOptions(config, 'StateSnapshotAsync', {
type: 'accumulate',
maxStateTokens: 4000,
maxSummaryTurns: 5,
}),
),
],
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContextCacheManager } from './contextCacheManager.js';
import { Storage } from '../config/storage.js';
import * as fs from 'node:fs';
vi.mock('node:fs');
vi.mock('../config/storage.js');
describe('ContextCacheManager', () => {
let manager: ContextCacheManager;
const mockMetadataPath = '/test/metadata.json';
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(Storage.getContextCacheMetadataPath).mockReturnValue(
mockMetadataPath,
);
manager = new ContextCacheManager();
});
it('should calculate stable SHA-256 hash', () => {
const si = 'You are a helpful assistant.';
const hash1 = manager.calculateHash(si);
const hash2 = manager.calculateHash(si);
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^[a-f0-9]{64}$/);
});
it('should return undefined if cache not found', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
expect(manager.getCache('nonexistent')).toBeUndefined();
});
it('should return entry if valid cache found', () => {
const hash = 'testhash';
const futureDate = new Date(Date.now() + 3600000).toISOString();
const entry = {
cacheName: 'cachedContents/123',
model: 'gemini-pro',
expiresAt: futureDate,
tokenCount: 1000,
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
version: '1.0',
entries: { [hash]: entry },
}),
);
const result = manager.getCache(hash);
expect(result).toEqual(entry);
});
it('should purge and return undefined if cache expired', () => {
const hash = 'expiredhash';
const pastDate = new Date(Date.now() - 3600000).toISOString();
const entry = {
cacheName: 'cachedContents/expired',
model: 'gemini-pro',
expiresAt: pastDate,
tokenCount: 1000,
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(
JSON.stringify({
version: '1.0',
entries: { [hash]: entry },
}),
);
const result = manager.getCache(hash);
expect(result).toBeUndefined();
expect(fs.writeFileSync).toHaveBeenCalled();
const saved = JSON.parse(
vi.mocked(fs.writeFileSync).mock.calls[0][1] as string,
);
expect(saved.entries[hash]).toBeUndefined();
});
it('should save metadata when setCache is called', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const hash = 'newhash';
const entry = {
cacheName: 'cachedContents/new',
model: 'gemini-pro',
expiresAt: new Date().toISOString(),
tokenCount: 1000,
};
manager.setCache(hash, entry);
expect(fs.writeFileSync).toHaveBeenCalled();
const saved = JSON.parse(
vi.mocked(fs.writeFileSync).mock.calls[0][1] as string,
);
expect(saved.entries[hash]).toEqual(entry);
});
});
@@ -0,0 +1,157 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import { Storage } from '../config/storage.js';
import { debugLogger } from '../utils/debugLogger.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
/**
* Metadata for a single Gemini Context Cache resource.
*/
export interface ContextCacheEntry {
/** The full resource name, e.g., 'cachedContents/xyz123' */
cacheName: string;
/** The model ID this cache was created for */
model: string;
/** ISO 8601 expiration timestamp */
expiresAt: string;
/** Number of tokens in the cached content */
tokenCount: number;
}
/**
* Schema for the local persistent metadata storage.
*/
export interface ContextCacheMetadata {
version: string;
/** Map of SHA-256(SI) -> ContextCacheEntry */
entries: Record<string, ContextCacheEntry>;
}
/**
* Manages the lifecycle and discovery of Gemini Context Caches.
* Uses a local metadata file to map System Instruction hashes to remote cache IDs.
*/
export class ContextCacheManager {
private metadata: ContextCacheMetadata | undefined;
private _metadataPath: string | undefined;
constructor() {}
private get metadataPath(): string {
if (!this._metadataPath) {
this._metadataPath = Storage.getContextCacheMetadataPath();
}
return this._metadataPath;
}
/**
* Resets the in-memory metadata and path. Used for testing.
*/
reset(): void {
this.metadata = undefined;
this._metadataPath = undefined;
}
private loadMetadata(): ContextCacheMetadata {
if (this.metadata) {
return this.metadata;
}
try {
if (fs.existsSync(this.metadataPath)) {
const content = fs.readFileSync(this.metadataPath, 'utf8');
const parsed = JSON.parse(content) as unknown;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this.metadata = parsed as ContextCacheMetadata;
} else {
this.metadata = { version: '1.0', entries: {} };
}
} catch (error) {
debugLogger.error('Failed to load context cache metadata:', error);
this.metadata = { version: '1.0', entries: {} };
}
return this.metadata;
}
private saveMetadata(): void {
if (!this.metadata) return;
try {
fs.writeFileSync(
this.metadataPath,
JSON.stringify(this.metadata, null, 2),
);
} catch (error) {
debugLogger.error('Failed to save context cache metadata:', error);
}
}
/**
* Calculates a stable SHA-256 hash of the System Instruction.
*/
calculateHash(systemInstruction: string): string {
return crypto.createHash('sha256').update(systemInstruction).digest('hex');
}
/**
* Calculates the token count of a system instruction string.
*/
calculateTokenCount(systemInstruction: string): number {
return estimateTokenCountSync([{ text: systemInstruction }]);
}
/**
* Looks up a hot cache for the given SI hash.
* Purges the entry if it has expired.
*/
getCache(hash: string): ContextCacheEntry | undefined {
const metadata = this.loadMetadata();
const entry = metadata.entries[hash];
if (entry) {
const now = new Date();
if (new Date(entry.expiresAt) > now) {
return entry;
} else {
// Purge expired entry
debugLogger.log(
`[ContextCache] Purging expired cache: ${entry.cacheName}`,
);
delete metadata.entries[hash];
this.saveMetadata();
}
}
return undefined;
}
/**
* Saves or updates a cache entry.
*/
setCache(hash: string, entry: ContextCacheEntry): void {
const metadata = this.loadMetadata();
metadata.entries[hash] = entry;
this.saveMetadata();
}
/**
* Removes a cache entry by hash.
*/
removeCache(hash: string): void {
const metadata = this.loadMetadata();
if (metadata.entries[hash]) {
delete metadata.entries[hash];
this.saveMetadata();
}
}
}
/** Global singleton instance */
export const contextCacheManager = new ContextCacheManager();
@@ -60,9 +60,7 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
// Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation
expect(projection[0].role).toBe('user');
expect(projection[0].parts![0].text).toBe(
'[Continuing from previous AI thoughts...]',
);
expect(projection[0].parts![0].text).toContain('User turn 17');
// Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies)
const contentNodes = projection.filter(
+11 -2
View File
@@ -72,7 +72,11 @@ export class ContextManager {
event.targets,
event.returnedNodes,
);
this.evaluateTriggers(new Set());
// 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();
@@ -126,10 +130,15 @@ export class ContextManager {
// Walk backwards finding nodes that fall out of the retained budget
for (let i = this.buffer.nodes.length - 1; i >= 0; i--) {
const node = this.buffer.nodes[i];
const priorTokens = rollingTokens;
rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([
node,
]);
if (rollingTokens > this.sidecar.config.budget.retainedTokens) {
// 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);
@@ -61,4 +61,169 @@ describe('render', () => {
expect(result.history).toEqual([{ text: '1' }, { text: '2' }]);
});
it('simulates the boundary knapsack problem (loose boundary policy)', async () => {
// 10k, 20k, 40k, 5k
const mockNodes: ConcreteNode[] = [
{
id: 'D',
type: NodeType.USER_PROMPT,
payload: {} as Part,
} as unknown as ConcreteNode,
{
id: 'C',
type: NodeType.AGENT_THOUGHT,
payload: {} as Part,
} as unknown as ConcreteNode,
{
id: 'B',
type: NodeType.USER_PROMPT,
payload: {} as Part,
} as unknown as ConcreteNode,
{
id: 'A',
type: NodeType.AGENT_THOUGHT,
payload: {} as Part,
} as unknown as ConcreteNode,
];
const tokenMap: Record<string, number> = {
D: 5000,
C: 40000,
B: 20000,
A: 10000,
};
const orchestrator = {
executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) =>
nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)),
),
} as unknown as PipelineOrchestrator;
const sidecar = {
config: {
budget: { maxTokens: 150000, retainedTokens: 65000 },
},
} as unknown as ContextProfile;
const currentTokens = 160000;
const env = {
llmClient: {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }),
},
tokenCalculator: {
calculateConcreteListTokens: vi.fn((nodes) => {
if (nodes.length === 1) return tokenMap[nodes[0].id];
return currentTokens;
}),
calculateTokenBreakdown: vi.fn(() => ({})),
},
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,
new Map(),
0,
new Set(),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const surviving = result.history.map((c: any) => c.text);
// Loose Boundary: A (10k), B (20k), C (40k). Total = 70k.
// Adding C pushes rolling total (70k) above retainedTokens (65k).
// Under loose policy, C survives. D is strictly older and drops.
expect(surviving).toEqual(['C', 'B', 'A']); // D is dropped
});
it('drops nodes that are STRICTLY older than the boundary node', async () => {
const mockNodes: ConcreteNode[] = [
{
id: 'A',
type: NodeType.USER_PROMPT,
payload: {} as Part,
} as unknown as ConcreteNode,
{
id: 'B',
type: NodeType.AGENT_THOUGHT,
payload: {} as Part,
} as unknown as ConcreteNode,
{
id: 'C',
type: NodeType.USER_PROMPT,
payload: {} as Part,
} as unknown as ConcreteNode,
];
const tokenMap: Record<string, number> = {
C: 40000,
B: 40000,
A: 10000,
};
const orchestrator = {
executeTriggerSync: vi.fn(async (trigger, nodes, agedOutNodes) =>
nodes.filter((n: ConcreteNode) => !agedOutNodes.has(n.id)),
),
} as unknown as PipelineOrchestrator;
const sidecar = {
config: {
budget: { maxTokens: 150000, retainedTokens: 65000 },
},
} as unknown as ContextProfile;
const currentTokens = 160000;
const env = {
llmClient: {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }),
},
tokenCalculator: {
calculateConcreteListTokens: vi.fn((nodes) => {
if (nodes.length === 1) return tokenMap[nodes[0].id];
return currentTokens;
}),
calculateTokenBreakdown: vi.fn(() => ({})),
},
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,
new Map(),
0,
new Set(),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const surviving = result.history.map((c: any) => c.text);
// C(40k), B(40k). Adding B pushes total to 80k. B is the boundary node and survives. A drops.
expect(surviving).toEqual(['B', 'C']); // A is dropped
});
});
+7 -1
View File
@@ -10,6 +10,7 @@ import type { ContextTracer } from '../tracer.js';
import type { ContextProfile } from '../config/profiles.js';
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { performCalibration } from '../utils/tokenCalibration.js';
/**
* Maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
@@ -68,6 +69,7 @@ export async function render(
tracer.logEvent('Render', 'Render Context for LLM', {
renderedContext: contents,
});
performCalibration(env, visibleNodes, contents);
return { history: contents, didApplyManagement: false };
}
const targetDelta = currentTokens - sidecar.config.budget.retainedTokens;
@@ -83,9 +85,12 @@ export async function render(
// 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;
if (rollingTokens > sidecar.config.budget.retainedTokens) {
// Loose Boundary Policy: Keep the node that crosses the boundary
if (priorTokens > sidecar.config.budget.retainedTokens) {
agedOutNodes.add(node.id);
}
}
@@ -113,5 +118,6 @@ export async function render(
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
renderedContextSanitized: contents,
});
performCalibration(env, visibleNodes, contents);
return { history: contents, didApplyManagement: true };
}
+4
View File
@@ -94,6 +94,10 @@ export async function initializeContextManager(
tracer,
4,
eventBus,
{
calibrateTokenCalculation:
!!process.env['GEMINI_CONTEXT_CALIBRATE_TOKEN_CALCULATIONS'],
},
);
const orchestrator = new PipelineOrchestrator(
@@ -13,6 +13,10 @@ import type { ContextGraphMapper } from '../graph/mapper.js';
export type { ContextTracer, ContextEventBus };
export interface RenderOptions {
calibrateTokenCalculation?: boolean;
}
export interface ContextEnvironment {
readonly llmClient: BaseLlmClient;
readonly promptId: string;
@@ -26,4 +30,5 @@ export interface ContextEnvironment {
readonly inbox: LiveInbox;
readonly behaviorRegistry: NodeBehaviorRegistry;
readonly graphMapper: ContextGraphMapper;
readonly renderOptions?: RenderOptions;
}
@@ -6,7 +6,7 @@
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import type { ContextTracer } from '../tracer.js';
import type { ContextEnvironment } from './environment.js';
import type { ContextEnvironment, RenderOptions } from './environment.js';
import type { ContextEventBus } from '../eventBus.js';
import { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
import { LiveInbox } from './inbox.js';
@@ -29,6 +29,7 @@ export class ContextEnvironmentImpl implements ContextEnvironment {
readonly tracer: ContextTracer,
readonly charsPerToken: number,
readonly eventBus: ContextEventBus,
readonly renderOptions?: RenderOptions,
) {
this.behaviorRegistry = new NodeBehaviorRegistry();
registerBuiltInBehaviors(this.behaviorRegistry);
@@ -18,6 +18,7 @@ import {
} from '../graph/types.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { LlmRole } from '../../telemetry/llmRole.js';
import { formatNodesForLlm } from '../utils/formatNodesForLlm.js';
export interface RollingSummaryProcessorOptions extends BackstopTargetOptions {
systemInstruction?: string;
@@ -47,19 +48,7 @@ export function createRollingSummaryProcessor(
const generateRollingSummary = async (
nodes: ConcreteNode[],
): Promise<string> => {
let transcript = '';
for (const node of nodes) {
const payload = node.payload;
let nodeContent = '';
if (payload.text) {
nodeContent = payload.text;
} else if (payload.functionCall) {
nodeContent = `CALL: ${payload.functionCall.name}(${JSON.stringify(payload.functionCall.args)})`;
} else if (payload.functionResponse) {
nodeContent = `RESPONSE: ${JSON.stringify(payload.functionResponse.response)}`;
}
transcript += `[${node.type}]: ${nodeContent}\n`;
}
const transcript = formatNodesForLlm(nodes);
const systemPrompt =
options.systemInstruction ??
@@ -44,14 +44,15 @@ describe('StateSnapshotAsyncProcessor', () => {
const targets = [nodeA, nodeB];
await worker.process(createMockProcessArgs(targets, targets, []));
// Ensure generateContent was called
expect(env.llmClient.generateContent).toHaveBeenCalled();
// Ensure generateJson was called
expect(env.llmClient.generateJson).toHaveBeenCalled();
// Verify it published to the inbox
expect(publishSpy).toHaveBeenCalledWith(
'PROPOSED_SNAPSHOT',
expect.objectContaining({
newText: 'Mock LLM summary response',
newText:
'{"active_tasks":[],"discovered_facts":[],"constraints_and_preferences":[],"recent_arc":[]}',
consumedIds: ['node-A', 'node-B'],
type: 'point-in-time',
}),
@@ -105,20 +106,20 @@ describe('StateSnapshotAsyncProcessor', () => {
expect(publishSpy).toHaveBeenCalledWith(
'PROPOSED_SNAPSHOT',
expect.objectContaining({
newText: 'Mock LLM summary response',
newText:
'{"active_tasks":[],"discovered_facts":[],"constraints_and_preferences":[],"recent_arc":[]}',
consumedIds: ['node-A', 'node-B', 'node-C'], // Aggregated!
type: 'accumulate',
}),
);
// Verify the LLM was called with the old snapshot prepended
expect(env.llmClient.generateContent).toHaveBeenCalledWith(
// Verify the LLM was called with the old snapshot provided in the prompt
expect(env.llmClient.generateJson).toHaveBeenCalledWith(
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({
parts: expect.arrayContaining([
expect.objectContaining({
text: expect.stringContaining('<old snapshot>'),
text: expect.stringContaining('CURRENT MASTER STATE'),
}),
]),
}),
@@ -141,4 +142,52 @@ describe('StateSnapshotAsyncProcessor', () => {
expect(env.llmClient.generateContent).not.toHaveBeenCalled();
expect(publishSpy).not.toHaveBeenCalled();
});
it('should use Global Lookback to find an existing snapshot in the graph when inbox is empty', async () => {
const env = createMockEnvironment();
const worker = createStateSnapshotAsyncProcessor(
'StateSnapshotAsyncProcessor',
env,
{ type: 'accumulate' },
);
// Create an old snapshot with existing JSON state
const oldStateJson = JSON.stringify({
discovered_facts: ['Global Lookback Async Works!'],
});
const oldSnapshot = createDummyNode(
'ep1',
NodeType.SNAPSHOT,
10,
{ payload: { text: oldStateJson } },
'old-snap',
);
const nodeC = createDummyNode(
'ep2',
NodeType.USER_PROMPT,
50,
{},
'node-C',
);
const targets = [oldSnapshot, nodeC];
const args = createMockProcessArgs(targets, targets, []); // Empty inbox!
await worker.process(args);
expect(env.llmClient.generateJson).toHaveBeenCalledWith(
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({
parts: expect.arrayContaining([
expect.objectContaining({
text: expect.stringContaining('Global Lookback Async Works!'),
}),
]),
}),
]),
}),
);
});
});
@@ -3,17 +3,21 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import type { JSONSchemaType } from 'ajv';
import type { AsyncContextProcessor, ProcessArgs } from '../pipeline.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { type ConcreteNode, NodeType } from '../graph/types.js';
import { SnapshotGenerator } from '../utils/snapshotGenerator.js';
import {
SnapshotGenerator,
findLatestSnapshotBaseline,
} from '../utils/snapshotGenerator.js';
import { debugLogger } from '../../utils/debugLogger.js';
import { NodeType } from '../graph/types.js';
export interface StateSnapshotAsyncProcessorOptions {
type?: 'accumulate' | 'point-in-time';
systemInstruction?: string;
maxSummaryTurns?: number;
maxStateTokens?: number;
}
export const StateSnapshotAsyncProcessorOptionsSchema: JSONSchemaType<StateSnapshotAsyncProcessorOptions> =
@@ -26,6 +30,8 @@ export const StateSnapshotAsyncProcessorOptionsSchema: JSONSchemaType<StateSnaps
nullable: true,
},
systemInstruction: { type: 'string', nullable: true },
maxSummaryTurns: { type: 'number', nullable: true },
maxStateTokens: { type: 'number', nullable: true },
},
required: [],
};
@@ -44,12 +50,13 @@ export function createStateSnapshotAsyncProcessor(
if (targets.length === 0) return;
try {
let nodesToSummarize = [...targets];
let previousConsumedIds: string[] = [];
const processorType = options.type ?? 'point-in-time';
const nodesToSummarize = [...targets];
let previousStateJson: string | undefined = undefined;
if (processorType === 'accumulate') {
// Look for the most recent unconsumed accumulate snapshot in the inbox
// 1. Look for the most recent unconsumed accumulate snapshot in the inbox
const proposedSnapshots = inbox.getMessages<{
newText: string;
consumedIds: string[];
@@ -72,26 +79,44 @@ export function createStateSnapshotAsyncProcessor(
env.inbox.drainConsumed(new Set([latest.id]));
previousConsumedIds = latest.payload.consumedIds;
previousStateJson = latest.payload.newText;
} else {
// 2. Global Lookback: No draft in inbox, scan the context graph for the last live snapshot
const baseline = findLatestSnapshotBaseline(targets);
const snapshotId = randomUUID();
const previousStateNode: ConcreteNode = {
id: snapshotId,
turnId: snapshotId,
type: NodeType.SNAPSHOT,
timestamp: latest.timestamp,
role: 'user',
payload: { text: latest.payload.newText },
};
nodesToSummarize = [previousStateNode, ...targets];
if (baseline) {
previousStateJson = baseline.text;
previousConsumedIds = [...baseline.abstractsIds];
} else {
debugLogger.log(
'[StateSnapshotAsyncProcessor] No previous snapshot found in Inbox or Graph. Initializing new Master State baseline in background.',
);
}
}
}
// If the snapshot happens to be inside our summary window, remove it so the LLM doesn't read it as raw transcript
if (previousStateJson) {
const summaryIdx = nodesToSummarize.findIndex(
(n) =>
n.type === NodeType.SNAPSHOT &&
n.payload.text === previousStateJson,
);
if (summaryIdx !== -1) {
nodesToSummarize.splice(summaryIdx, 1);
}
}
if (nodesToSummarize.length === 0) return;
const snapshotText = await generator.synthesizeSnapshot(
nodesToSummarize,
options.systemInstruction,
previousStateJson,
{
maxSummaryTurns: options.maxSummaryTurns,
maxStateTokens: options.maxStateTokens,
},
);
const newConsumedIds = [
...previousConsumedIds,
...targets.map((t) => t.id),
@@ -167,8 +167,92 @@ describe('StateSnapshotProcessor', () => {
const result = await processor.process(createMockProcessArgs(targets));
// Should synthesize a new snapshot synchronously
expect(env.llmClient.generateContent).toHaveBeenCalled();
expect(env.llmClient.generateJson).toHaveBeenCalled();
expect(result.length).toBe(1); // nodeA is no longer protected, so everything is snapshotted
expect(result[0].type).toBe(NodeType.SNAPSHOT);
});
it('should use Global Lookback to find an existing snapshot in the graph as the baseline', async () => {
const env = createMockEnvironment();
const processor = createStateSnapshotProcessor(
'StateSnapshotProcessor',
env,
{ target: 'incremental' },
);
// Create an old snapshot with existing JSON state
const oldStateJson = JSON.stringify({
discovered_facts: ['Global Lookback Works!'],
});
const oldSnapshot = createDummyNode(
'ep1',
NodeType.SNAPSHOT,
10,
{ payload: { text: oldStateJson } },
'old-snap',
);
const nodeA = createDummyNode(
'ep2',
NodeType.USER_PROMPT,
50,
{},
'node-A',
);
// targets array contains the snapshot
const targets = [oldSnapshot, nodeA];
await processor.process(createMockProcessArgs(targets));
// The SnapshotGenerator should have been called with the oldStateJson as the baseline
expect(env.llmClient.generateJson).toHaveBeenCalledWith(
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({
parts: expect.arrayContaining([
expect.objectContaining({
text: expect.stringContaining('Global Lookback Works!'),
}),
]),
}),
]),
}),
);
});
it('should garbage collect the old baseline snapshot from the live graph when creating a new sync snapshot', async () => {
const env = createMockEnvironment();
const processor = createStateSnapshotProcessor(
'StateSnapshotProcessor',
env,
{ target: 'incremental' },
);
const oldSnapshot = createDummyNode(
'ep1',
NodeType.SNAPSHOT,
10,
{ payload: { text: '{}' } },
'old-snap',
);
const nodeA = createDummyNode(
'ep2',
NodeType.USER_PROMPT,
50,
{},
'node-A',
);
// The processor summarizes these 2 nodes
const result = await processor.process(
createMockProcessArgs([oldSnapshot, nodeA]),
);
// It should have replaced BOTH the old snapshot and the new node with ONE new snapshot
expect(result.length).toBe(1);
expect(result[0].type).toBe(NodeType.SNAPSHOT);
expect(result[0].id).not.toBe('old-snap');
expect(result[0].abstractsIds).toContain('old-snap');
expect(result[0].abstractsIds).toContain('node-A');
});
});
@@ -12,12 +12,17 @@ import type {
} from '../pipeline.js';
import type { ContextEnvironment } from '../pipeline/environment.js';
import { type ConcreteNode, type Snapshot, NodeType } from '../graph/types.js';
import { SnapshotGenerator } from '../utils/snapshotGenerator.js';
import {
SnapshotGenerator,
findLatestSnapshotBaseline,
} from '../utils/snapshotGenerator.js';
import { debugLogger } from '../../utils/debugLogger.js';
export interface StateSnapshotProcessorOptions extends BackstopTargetOptions {
model?: string;
systemInstruction?: string;
maxSummaryTurns?: number;
maxStateTokens?: number;
}
export const StateSnapshotProcessorOptionsSchema: JSONSchemaType<StateSnapshotProcessorOptions> =
@@ -32,6 +37,8 @@ export const StateSnapshotProcessorOptionsSchema: JSONSchemaType<StateSnapshotPr
freeTokensTarget: { type: 'number', nullable: true },
model: { type: 'string', nullable: true },
systemInstruction: { type: 'string', nullable: true },
maxSummaryTurns: { type: 'number', nullable: true },
maxStateTokens: { type: 'number', nullable: true },
},
required: [],
};
@@ -141,12 +148,43 @@ export function createStateSnapshotProcessor(
if (nodesToSummarize.length < 2) return targets; // Not enough context
let previousStateJson: string | undefined = undefined;
let baselineIdToConsume: string | undefined = undefined;
// Global Lookback: Find the absolute most recent snapshot anywhere in the active context
const baseline = findLatestSnapshotBaseline(targets);
if (baseline) {
previousStateJson = baseline.text;
// If the snapshot happens to be inside our summary window, remove it so the LLM doesn't read it as raw transcript
const summaryIdx = nodesToSummarize.findIndex(
(n) => n.id === baseline.id,
);
if (summaryIdx !== -1) {
baselineIdToConsume = baseline.id;
nodesToSummarize.splice(summaryIdx, 1);
}
} else {
debugLogger.log(
'[StateSnapshotProcessor] No previous snapshot found in context graph. Initializing new Master State baseline.',
);
}
try {
const snapshotText = await generator.synthesizeSnapshot(
nodesToSummarize,
options.systemInstruction,
previousStateJson,
{
maxSummaryTurns: options.maxSummaryTurns,
maxStateTokens: options.maxStateTokens,
},
);
const newId = randomUUID();
const consumedIds = nodesToSummarize.map((n) => n.id);
if (baselineIdToConsume && !consumedIds.includes(baselineIdToConsume)) {
consumedIds.push(baselineIdToConsume);
}
const snapshotNode: Snapshot = {
id: newId,
turnId: newId,
@@ -154,10 +192,9 @@ export function createStateSnapshotProcessor(
timestamp: nodesToSummarize[nodesToSummarize.length - 1].timestamp,
role: 'user',
payload: { text: snapshotText },
abstractsIds: nodesToSummarize.map((n) => n.id),
abstractsIds: [...consumedIds],
};
const consumedIds = nodesToSummarize.map((n) => n.id);
const returnedNodes = targets.filter(
(t) => !consumedIds.includes(t.id),
);
File diff suppressed because one or more lines are too long
@@ -9,11 +9,7 @@ import fs from 'node:fs';
import { SimulationHarness } from './simulationHarness.js';
import { createMockLlmClient } from '../testing/contextTestUtils.js';
import type { ContextProfile } from '../config/profiles.js';
import { createToolMaskingProcessor } from '../processors/toolMaskingProcessor.js';
import { createBlobDegradationProcessor } from '../processors/blobDegradationProcessor.js';
import { createStateSnapshotProcessor } from '../processors/stateSnapshotProcessor.js';
import { createHistoryTruncationProcessor } from '../processors/historyTruncationProcessor.js';
import { createStateSnapshotAsyncProcessor } from '../processors/stateSnapshotAsyncProcessor.js';
import { stressTestProfile } from '../config/profiles.js';
expect.addSnapshotSerializer({
test: (val) =>
@@ -52,57 +48,22 @@ describe('System Lifecycle Golden Tests', () => {
vi.restoreAllMocks();
});
const getAggressiveConfig = (): ContextProfile => ({
name: 'Aggressive Test',
config: {
budget: { maxTokens: 1000, retainedTokens: 500 }, // Extremely tight limits
},
buildPipelines: (env) => [
{
name: 'Pressure Relief', // Emits from eventBus 'retained_exceeded'
triggers: ['retained_exceeded'],
processors: [
createBlobDegradationProcessor('BlobDegradationProcessor', env),
createToolMaskingProcessor('ToolMaskingProcessor', env, {
stringLengthThresholdTokens: 50,
}),
createStateSnapshotProcessor('StateSnapshotProcessor', env, {}),
],
},
{
name: 'Immediate Sanitization', // The magic string the projector is hardcoded to use
triggers: ['retained_exceeded'],
processors: [
createHistoryTruncationProcessor(
'HistoryTruncationProcessor',
env,
{},
),
],
},
],
buildAsyncPipelines: (env) => [
{
name: 'Async',
triggers: ['nodes_aged_out'],
processors: [
createStateSnapshotAsyncProcessor(
'StateSnapshotAsyncProcessor',
env,
{},
),
],
},
],
});
const mockLlmClient = createMockLlmClient([
'<MOCKED_STATE_SNAPSHOT_SUMMARY>',
]);
// Uses dynamic role-based mocking to differentiate Snapshot vs Distillation output automatically.
const mockLlmClient = createMockLlmClient();
it('Scenario 1: Organic Growth with Huge Tool Output & Images', async () => {
// Override stressTestProfile limits slightly to ensure immediate overflow
// without having to push 50,000 characters to cross the generalist boundaries.
const customProfile: ContextProfile = {
...stressTestProfile,
config: {
...stressTestProfile.config,
budget: { maxTokens: 1000, retainedTokens: 500 },
},
};
const harness = await SimulationHarness.create(
getAggressiveConfig(),
customProfile,
mockLlmClient,
);
@@ -169,6 +130,9 @@ describe('System Lifecycle Golden Tests', () => {
{ role: 'model', parts: [{ text: 'Yes we can.' }] },
]);
// Give the background tasks a moment to inject the snapshot into the graph
await new Promise((resolve) => setTimeout(resolve, 50));
// Get final state
const goldenState = await harness.getGoldenState();
@@ -212,54 +176,117 @@ describe('System Lifecycle Golden Tests', () => {
expect(goldenState).toMatchSnapshot();
});
it('Scenario 3: Async-Driven Background GC', async () => {
const gcConfig: ContextProfile = {
name: 'GC Test Config',
it('Scenario 3: Node Distillation of Large Historical Messages', async () => {
// 1 Turn = ~2520 tokens.
// retainedTokens = 4000 ensures Turn 0 is kept intact until Turn 1 pushes the total to ~5040.
const customProfile: ContextProfile = {
...stressTestProfile,
config: {
budget: { maxTokens: 200, retainedTokens: 100 },
},
buildPipelines: () => [],
buildAsyncPipelines: (env) => [
{
name: 'Async',
triggers: ['nodes_aged_out'],
processors: [
createStateSnapshotAsyncProcessor(
'StateSnapshotAsyncProcessor',
env,
{},
),
],
...stressTestProfile.config,
budget: { maxTokens: 10000, retainedTokens: 4000 },
processorOptions: {
...stressTestProfile.config?.processorOptions,
NodeDistillation: {
type: 'NodeDistillationProcessor',
options: {
nodeThresholdTokens: 1000, // 1250 > 1000, so older messages will be distilled
},
},
},
],
},
// Disable async pipelines (StateSnapshots) so they don't compete with the Normalization pipeline
buildAsyncPipelines: () => [],
};
const harness = await SimulationHarness.create(gcConfig, mockLlmClient);
const harness = await SimulationHarness.create(
customProfile,
mockLlmClient,
);
// Turn 0
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'A'.repeat(50) }] },
{ role: 'model', parts: [{ text: 'B'.repeat(50) }] },
{ role: 'user', parts: [{ text: 'A'.repeat(5000) }] },
{ role: 'model', parts: [{ text: 'B'.repeat(5000) }] },
]);
// Turn 1 (Should trigger StateSnapshotasync pipeline because we exceed 100 retainedTokens)
// Turn 1
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'C'.repeat(50) }] },
{ role: 'model', parts: [{ text: 'D'.repeat(50) }] },
{ role: 'user', parts: [{ text: 'C'.repeat(5000) }] },
{ role: 'model', parts: [{ text: 'D'.repeat(5000) }] },
]);
// Give the async background pipeline an extra beat to complete its async execution and emit variants
await new Promise((resolve) => setTimeout(resolve, 50));
// Turn 2
await harness.simulateTurn([
{ role: 'user', parts: [{ text: 'E'.repeat(50) }] },
{ role: 'model', parts: [{ text: 'F'.repeat(50) }] },
{ role: 'user', parts: [{ text: 'E'.repeat(5000) }] },
{ role: 'model', parts: [{ text: 'F'.repeat(5000) }] },
]);
const goldenState = await harness.getGoldenState();
// We should see ROLLING_SUMMARY nodes injected into the graph, proving the async pipeline ran in the background
// We should see MOCKED_DISTILLED_NODE replacing older bloated messages, while recent messages are untouched.
expect(goldenState).toMatchSnapshot();
});
it('Scenario 4: Async-Driven Background GC via State Snapshots', async () => {
// Mathematical Token Budgeting:
// 200 chars ≈ 50 tokens.
// 1 Turn (User + Model + Overhead) ≈ 50 + 50 + 20 = 120 Tokens.
const customProfile: ContextProfile = {
...stressTestProfile,
config: {
...stressTestProfile.config,
// Retain 3 Turns (~360 tokens). Max 5 Turns (~600 tokens).
budget: { maxTokens: 600, retainedTokens: 360 },
},
};
const harness = await SimulationHarness.create(
customProfile,
mockLlmClient,
);
const createMessage = (index: number) =>
`Msg ${index} `.repeat(25).padEnd(200, '.');
// Turn 0 (~120 tokens) Total: 120
await harness.simulateTurn([
{ role: 'user', parts: [{ text: createMessage(0) }] },
{ role: 'model', parts: [{ text: createMessage(1) }] },
]);
// Turn 1 (~120 tokens) Total: 240
await harness.simulateTurn([
{ role: 'user', parts: [{ text: createMessage(2) }] },
{ role: 'model', parts: [{ text: createMessage(3) }] },
]);
// Turn 2 (~120 tokens) Total: 360 (At retainedTokens boundary)
await harness.simulateTurn([
{ role: 'user', parts: [{ text: createMessage(4) }] },
{ role: 'model', parts: [{ text: createMessage(5) }] },
]);
// Turn 3 (~120 tokens) Total: 480 (Exceeds retainedTokens! Triggers GC on Turn 0 & 1)
await harness.simulateTurn([
{ role: 'user', parts: [{ text: createMessage(6) }] },
{ role: 'model', parts: [{ text: createMessage(7) }] },
]);
// Give the async background snapshot pipeline time to complete
await new Promise((resolve) => setTimeout(resolve, 50));
// Turn 4 (~120 tokens).
// If GC succeeded, Turn 0 and 1 are now a ~10 token snapshot.
// Total should be: 10 (Snapshot) + 120 (Turn 2) + 120 (Turn 3) + 120 (Turn 4) = ~370 tokens.
await harness.simulateTurn([
{ role: 'user', parts: [{ text: createMessage(8) }] },
{ role: 'model', parts: [{ text: createMessage(9) }] },
]);
const goldenState = await harness.getGoldenState();
// We should see a MOCKED_STATE_SNAPSHOT_SUMMARY rolling up Turns 0 and 1,
// while Turns 2, 3, and 4 remain fully intact.
expect(goldenState).toMatchSnapshot();
});
});

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