Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fda0e75dd |
@@ -5,10 +5,7 @@
|
||||
"autoMemory": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": true,
|
||||
"adk": {
|
||||
"agentSessionNoninteractiveEnabled": true
|
||||
}
|
||||
"voiceMode": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -174,9 +174,9 @@ runs:
|
||||
npm publish \
|
||||
--dry-run="${INPUTS_DRY_RUN}" \
|
||||
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
--no-tag
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp
|
||||
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false
|
||||
fi
|
||||
|
||||
- name: '🔗 Install latest core package'
|
||||
@@ -222,9 +222,9 @@ runs:
|
||||
npm publish \
|
||||
--dry-run="${INPUTS_DRY_RUN}" \
|
||||
--workspace="${INPUTS_CLI_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
--no-tag
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
|
||||
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false
|
||||
fi
|
||||
|
||||
- name: 'Get a2a-server Token'
|
||||
@@ -249,9 +249,9 @@ runs:
|
||||
npm publish \
|
||||
--dry-run="${INPUTS_DRY_RUN}" \
|
||||
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
--no-tag
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp
|
||||
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false
|
||||
fi
|
||||
|
||||
- name: '🔬 Verify NPM release by version'
|
||||
|
||||
@@ -85,51 +85,14 @@ module.exports = async ({ github, context, core }) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
let labelsToAdd = entry.labels_to_add || [];
|
||||
const labelsToAdd = entry.labels_to_add || [];
|
||||
labelsToAdd.push('status/bot-triaged');
|
||||
|
||||
let labelsToRemove = entry.labels_to_remove || [];
|
||||
|
||||
labelsToRemove.push('status/need-triage');
|
||||
|
||||
if (labelsToAdd.includes('status/manual-triage')) {
|
||||
// If the AI flagged it for manual triage, remove bot-triaged if it exists
|
||||
labelsToRemove.push('status/bot-triaged');
|
||||
// Ensure we don't accidentally try to add bot-triaged if the AI returned it
|
||||
labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged');
|
||||
} else {
|
||||
// Standard successful bot triage
|
||||
labelsToAdd.push('status/bot-triaged');
|
||||
}
|
||||
|
||||
// Deduplicate arrays
|
||||
labelsToAdd = [...new Set(labelsToAdd)];
|
||||
// Deduplicate array
|
||||
labelsToRemove = [...new Set(labelsToRemove)];
|
||||
|
||||
// Enforce mutually exclusive area labels
|
||||
const areaLabelsToAdd = labelsToAdd.filter((l) => l.startsWith('area/'));
|
||||
if (areaLabelsToAdd.length > 1) {
|
||||
core.warning(
|
||||
`Issue #${issueNumber} has multiple area labels to add: ${areaLabelsToAdd.join(', ')}. Keeping only the first one.`,
|
||||
);
|
||||
const firstArea = areaLabelsToAdd[0];
|
||||
labelsToAdd = labelsToAdd.filter(
|
||||
(l) => !l.startsWith('area/') || l === firstArea,
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce mutually exclusive priority labels
|
||||
const priorityLabelsToAdd = labelsToAdd.filter((l) =>
|
||||
l.startsWith('priority/'),
|
||||
);
|
||||
if (priorityLabelsToAdd.length > 1) {
|
||||
core.warning(
|
||||
`Issue #${issueNumber} has multiple priority labels to add: ${priorityLabelsToAdd.join(', ')}. Keeping only the first one.`,
|
||||
);
|
||||
const firstPriority = priorityLabelsToAdd[0];
|
||||
labelsToAdd = labelsToAdd.filter(
|
||||
(l) => !l.startsWith('priority/') || l === firstPriority,
|
||||
);
|
||||
}
|
||||
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
core.info('Fetching open issues to check for conflicting labels...');
|
||||
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const conflictingLabelIssues = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) continue;
|
||||
|
||||
const areaLabels = issue.labels
|
||||
.filter((l) => l.name && l.name.startsWith('area/'))
|
||||
.map((l) => l.name);
|
||||
|
||||
const priorityLabels = issue.labels
|
||||
.filter((l) => l.name && l.name.startsWith('priority/'))
|
||||
.map((l) => l.name);
|
||||
|
||||
if (areaLabels.length > 1 || priorityLabels.length > 1) {
|
||||
let message = `Issue #${issue.number} has conflicting labels:`;
|
||||
if (areaLabels.length > 1)
|
||||
message += ` multiple areas (${areaLabels.join(', ')}).`;
|
||||
if (priorityLabels.length > 1)
|
||||
message += ` multiple priorities (${priorityLabels.join(', ')}).`;
|
||||
|
||||
core.info(message);
|
||||
|
||||
conflictingLabelIssues.push({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
body: issue.body || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to 50 to avoid overwhelming the AI in a single run
|
||||
const issuesToProcess = conflictingLabelIssues.slice(0, 50);
|
||||
|
||||
fs.writeFileSync(
|
||||
'conflicting_labels_issues.json',
|
||||
JSON.stringify(issuesToProcess, null, 2),
|
||||
);
|
||||
|
||||
core.info(
|
||||
`Found ${conflictingLabelIssues.length} issues with conflicting labels. Wrote ${issuesToProcess.length} to conflicting_labels_issues.json`,
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,6 @@ jobs:
|
||||
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
ref: '${{ inputs.ref || github.ref }}'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
|
||||
@@ -148,7 +148,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ needs.parse_run_context.outputs.sha }}'
|
||||
repository: '${{ needs.parse_run_context.outputs.repository }}'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js ${{ matrix.node-version }}'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
@@ -194,7 +193,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ needs.parse_run_context.outputs.sha }}'
|
||||
repository: '${{ needs.parse_run_context.outputs.repository }}'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
@@ -235,7 +233,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ needs.parse_run_context.outputs.sha }}'
|
||||
repository: '${{ needs.parse_run_context.outputs.repository }}'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
@@ -317,7 +314,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ needs.parse_run_context.outputs.sha }}'
|
||||
repository: '${{ needs.parse_run_context.outputs.repository }}'
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
|
||||
@@ -57,7 +57,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -131,8 +130,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: 'Link Checker'
|
||||
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
|
||||
with:
|
||||
@@ -160,8 +157,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js ${{ matrix.node-version }}'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
@@ -257,8 +252,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js ${{ matrix.node-version }}'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
@@ -346,7 +339,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
|
||||
- name: 'Initialize CodeQL'
|
||||
@@ -371,7 +363,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -399,7 +390,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.branch_ref || github.ref }}'
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
|
||||
@@ -43,7 +43,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ github.event.pull_request.head.sha }}'
|
||||
repository: '${{ github.repository }}'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js ${{ matrix.node-version }}'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
@@ -87,7 +86,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ github.event.pull_request.head.sha }}'
|
||||
repository: '${{ github.repository }}'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
@@ -127,7 +125,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ github.event.pull_request.head.sha }}'
|
||||
repository: '${{ github.repository }}'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js 20.x'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
|
||||
|
||||
@@ -19,7 +19,6 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: 'main'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
|
||||
|
||||
@@ -24,8 +24,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Pages'
|
||||
uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5
|
||||
|
||||
@@ -38,7 +38,6 @@ jobs:
|
||||
with:
|
||||
# Check out the trusted code from main for detection
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Detect Steering Changes'
|
||||
id: 'detect'
|
||||
@@ -103,7 +102,6 @@ jobs:
|
||||
# This only runs AFTER manual approval
|
||||
ref: '${{ github.event.pull_request.head.sha }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Remove Approval Notification'
|
||||
# Run even if other steps fail, to ensure we clean up the "Action Required" message
|
||||
|
||||
@@ -46,8 +46,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
@@ -107,8 +105,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Download Logs'
|
||||
uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4
|
||||
|
||||
@@ -48,8 +48,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Log in to GitHub Container Registry'
|
||||
uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3
|
||||
|
||||
@@ -90,8 +90,6 @@ jobs:
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
|
||||
@@ -2,8 +2,7 @@ name: '🧠 Gemini CLI Bot: Brain'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Nightly (Strategic Metrics)
|
||||
- cron: '0 */4 * * *' # Every 4 hours (Issue Fixing)
|
||||
- cron: '0 0 * * *' # Every 24 hours
|
||||
issue_comment:
|
||||
types: ['created']
|
||||
workflow_dispatch:
|
||||
@@ -27,89 +26,69 @@ on:
|
||||
enable_prs:
|
||||
description: 'Enable PRs (automatically promote changes to PRs)'
|
||||
type: 'boolean'
|
||||
default: true
|
||||
mandate:
|
||||
description: 'Mandate to execute'
|
||||
type: 'choice'
|
||||
options:
|
||||
- 'auto'
|
||||
- 'issue-fixer'
|
||||
- 'metrics'
|
||||
- 'interactive'
|
||||
default: 'auto'
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || github.ref }}'
|
||||
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number || github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
reasoning:
|
||||
name: 'Brain (Reasoning Layer)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 60
|
||||
if: |
|
||||
github.repository == 'google-gemini/gemini-cli' && (
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch') ||
|
||||
(github.event_name == 'issue_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association))
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive != 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive == 'true') ||
|
||||
(github.event_name == 'issue_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association)) ||
|
||||
(github.event_name == 'pull_request_review_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association))
|
||||
)
|
||||
# The reasoning phase is strictly readonly.
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'read'
|
||||
actions: 'read'
|
||||
pull-requests: 'read'
|
||||
outputs:
|
||||
sha: '${{ steps.get_sha.outputs.sha }}'
|
||||
target_sha: '${{ steps.get_target_sha.outputs.sha }}'
|
||||
patch_base_sha: '${{ steps.generate_patch.outputs.patch_base_sha }}'
|
||||
actions: 'read'
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
steps:
|
||||
- name: 'Checkout Branch (Agent Code)'
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
|
||||
run: |
|
||||
REF="${{ github.ref }}"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
|
||||
if [ -n "$PR_HEAD" ]; then
|
||||
REF="$PR_HEAD"
|
||||
fi
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.ref }}'
|
||||
path: 'agent-code'
|
||||
ref: '${{ steps.determine_ref.outputs.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Get Current SHA'
|
||||
id: 'get_sha'
|
||||
working-directory: 'agent-code'
|
||||
run: 'echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"'
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'agent-code/package-lock.json'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
working-directory: 'agent-code'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build Gemini CLI'
|
||||
working-directory: 'agent-code'
|
||||
run: 'npm run bundle'
|
||||
|
||||
- name: 'Checkout Main (Target Repo)'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: 'main'
|
||||
path: 'repo-target'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Get Target SHA'
|
||||
id: 'get_target_sha'
|
||||
working-directory: 'repo-target'
|
||||
run: 'echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"'
|
||||
|
||||
- name: 'Download Previous State'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.clear_memory }}" = "true" ]; then
|
||||
@@ -118,19 +97,19 @@ jobs:
|
||||
fi
|
||||
|
||||
# Find the last successful run of this workflow
|
||||
LAST_RUN_ID=$(gh run list -R "${{ github.repository }}" --workflow "${{ github.workflow }}" --status success --limit 1 --json databaseId --jq '.[0].databaseId')
|
||||
LAST_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" --status success --limit 1 --json databaseId --jq '.[0].databaseId')
|
||||
|
||||
if [ -n "$LAST_RUN_ID" ]; then
|
||||
echo "Found previous successful run: $LAST_RUN_ID"
|
||||
|
||||
# Download brain memory to a temp dir so we can selectively restore only persistent state
|
||||
mkdir -p .temp_brain_data
|
||||
gh run download "$LAST_RUN_ID" -R "${{ github.repository }}" -n brain-data -D .temp_brain_data || echo "brain-data not found"
|
||||
gh run download "$LAST_RUN_ID" -n brain-data -D .temp_brain_data || echo "brain-data not found"
|
||||
|
||||
# Restore only persistent memory files
|
||||
cp .temp_brain_data/tools/gemini-cli-bot/lessons-learned.md repo-target/tools/gemini-cli-bot/lessons-learned.md 2>/dev/null || true
|
||||
mkdir -p repo-target/tools/gemini-cli-bot/history/
|
||||
cp .temp_brain_data/tools/gemini-cli-bot/history/*.csv repo-target/tools/gemini-cli-bot/history/ 2>/dev/null || true
|
||||
cp .temp_brain_data/tools/gemini-cli-bot/lessons-learned.md tools/gemini-cli-bot/lessons-learned.md 2>/dev/null || true
|
||||
mkdir -p tools/gemini-cli-bot/history/
|
||||
cp .temp_brain_data/tools/gemini-cli-bot/history/*.csv tools/gemini-cli-bot/history/ 2>/dev/null || true
|
||||
rm -rf .temp_brain_data
|
||||
else
|
||||
echo "No previous successful run found."
|
||||
@@ -138,62 +117,24 @@ jobs:
|
||||
|
||||
- name: 'Collect Current Metrics'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
working-directory: 'agent-code'
|
||||
run: 'npx tsx tools/gemini-cli-bot/metrics/index.ts'
|
||||
|
||||
- name: 'Run Brain Phases'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
GEMINI_CLI_HOME: '../agent-code/tools/gemini-cli-bot'
|
||||
ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'true' }}"
|
||||
ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'false' }}"
|
||||
TRIGGER_ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}'
|
||||
TRIGGER_COMMENT_ID: '${{ github.event.comment.id || github.event.inputs.comment_id }}'
|
||||
# Enable detailed activity logging for debugging
|
||||
GEMINI_TELEMETRY_ENABLED: 'true'
|
||||
GEMINI_TELEMETRY_LOG_PROMPTS: 'true'
|
||||
GEMINI_TELEMETRY_OUTFILE: 'brain-telemetry.json'
|
||||
GEMINI_DEBUG_LOG_FILE: 'brain-debug.log'
|
||||
GH_PAGER: ''
|
||||
working-directory: 'repo-target'
|
||||
run: |
|
||||
# Determine intent and prompt
|
||||
MANDATE_INPUT="${{ github.event.inputs.mandate || 'auto' }}"
|
||||
|
||||
# Initialize defaults
|
||||
PROMPT_FILE="../agent-code/tools/gemini-cli-bot/brain/scheduled.md"
|
||||
MANDATE="Your specific mandate for this run: Implement surgical fixes for repository issues (issue-fixer skill)."
|
||||
|
||||
# Resolve Mandate and Prompt File
|
||||
if [ "$MANDATE_INPUT" = "issue-fixer" ]; then
|
||||
echo "Trigger: Manual Override (issue-fixer)"
|
||||
MANDATE="Your specific mandate for this run: Implement surgical fixes for repository issues (issue-fixer skill)."
|
||||
elif [ "$MANDATE_INPUT" = "metrics" ]; then
|
||||
echo "Trigger: Manual Override (metrics)"
|
||||
MANDATE="Your specific mandate for this run: Analyze repository metrics to identify bottlenecks and self-evolve (metrics skill)."
|
||||
elif [ "$MANDATE_INPUT" = "interactive" ]; then
|
||||
echo "Trigger: Manual Override (interactive)"
|
||||
PROMPT_FILE="../agent-code/tools/gemini-cli-bot/brain/interactive.md"
|
||||
MANDATE="Your specific mandate for this run: Respond to the user request in <untrusted_context>."
|
||||
elif [ "${{ github.event_name }}" = "issue_comment" ] || [ "${{ github.event.inputs.run_interactive }}" = "true" ]; then
|
||||
echo "Trigger: Issue/PR Comment or Interactive Dispatch"
|
||||
PROMPT_FILE="../agent-code/tools/gemini-cli-bot/brain/interactive.md"
|
||||
MANDATE="Your specific mandate for this run: Respond to the user request in <untrusted_context>."
|
||||
elif [ "${{ github.event.schedule }}" = "0 0 * * *" ]; then
|
||||
echo "Trigger: Nightly Schedule (Metrics)"
|
||||
MANDATE="Your specific mandate for this run: Analyze repository metrics to identify bottlenecks and self-evolve (metrics skill)."
|
||||
else
|
||||
echo "Trigger: Scheduled or Manual Dispatch (Default: Issue-Fixer)"
|
||||
PROMPT_PATH="tools/gemini-cli-bot/brain/metrics.md"
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ] || [ "${{ github.event.inputs.run_interactive }}" = "true" ]; then
|
||||
PROMPT_PATH="tools/gemini-cli-bot/brain/interactive.md"
|
||||
export ENABLE_PRS="true"
|
||||
fi
|
||||
|
||||
echo "Selected Prompt: $PROMPT_FILE"
|
||||
echo "Selected Mandate: $MANDATE"
|
||||
|
||||
# Prepare Context if available
|
||||
touch trigger_context.md
|
||||
if [ -n "$TRIGGER_ISSUE_NUMBER" ]; then
|
||||
echo "<untrusted_context>" > trigger_context.md
|
||||
@@ -211,18 +152,9 @@ jobs:
|
||||
echo "</untrusted_context>" >> trigger_context.md
|
||||
fi
|
||||
|
||||
# Pass PR Enablement Directive
|
||||
PR_DIRECTIVE="PR creation is DISABLED. You MUST NOT stage files."
|
||||
if [ "${{ github.event.inputs.enable_prs || 'true' }}" = "true" ] || [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
PR_DIRECTIVE="PR creation is ENABLED. You MUST activate the 'prs' skill to stage changes if proposing fixes."
|
||||
fi
|
||||
cat trigger_context.md "$PROMPT_PATH" tools/gemini-cli-bot/brain/common.md > combined_prompt.md
|
||||
|
||||
# Assemble final prompt: Context + Base Brain + Specific Mandate
|
||||
echo "System: $PR_DIRECTIVE" > combined_prompt.md
|
||||
cat trigger_context.md "$PROMPT_FILE" >> combined_prompt.md
|
||||
echo -e "\n\n# MANDATE FOR THIS RUN\n$MANDATE" >> combined_prompt.md
|
||||
|
||||
node ../agent-code/bundle/gemini.js --policy ../agent-code/tools/gemini-cli-bot/ci-policy.toml --prompt="$(cat combined_prompt.md)"
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat combined_prompt.md)"
|
||||
|
||||
if [ -n "$TRIGGER_ISSUE_NUMBER" ] && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
|
||||
echo "Agent failed to respond. Generating fallback error message."
|
||||
@@ -232,21 +164,17 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 'Run Critique Phase'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
working-directory: 'repo-target'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
GEMINI_CLI_HOME: '../agent-code/tools/gemini-cli-bot'
|
||||
GH_PAGER: ''
|
||||
run: |
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes staged. Skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
else
|
||||
node ../agent-code/bundle/gemini.js --policy ../agent-code/tools/gemini-cli-bot/ci-policy.toml --prompt="$(cat ../agent-code/tools/gemini-cli-bot/.gemini/skills/critique/SKILL.md)" 2>&1 | tee critique_output.log
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
|
||||
|
||||
if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
@@ -257,45 +185,29 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: 'Generate Patch'
|
||||
id: 'generate_patch'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
working-directory: 'repo-target'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
run: |
|
||||
touch bot-changes.patch
|
||||
touch pr-description.md
|
||||
touch pr-labels.txt
|
||||
touch branch-name.txt
|
||||
touch issue-comment.md
|
||||
touch pr-comment.md
|
||||
echo "patch_base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
if [ -f critique_result.txt ] && grep -q "\[APPROVED\]" critique_result.txt && ! grep -q "\[REJECTED\]" critique_result.txt; then
|
||||
git diff --staged > bot-changes.patch
|
||||
else
|
||||
echo "Critique did not approve. Skipping patch generation."
|
||||
fi
|
||||
|
||||
- name: 'Stage Artifacts'
|
||||
if: 'always()'
|
||||
run: |
|
||||
mkdir -p staged-artifacts/tools/gemini-cli-bot/history
|
||||
cp repo-target/tools/gemini-cli-bot/lessons-learned.md staged-artifacts/tools/gemini-cli-bot/ 2>/dev/null || true
|
||||
cp repo-target/tools/gemini-cli-bot/history/*.csv staged-artifacts/tools/gemini-cli-bot/history/ 2>/dev/null || true
|
||||
cp repo-target/brain-telemetry.json staged-artifacts/ 2>/dev/null || true
|
||||
cp repo-target/brain-debug.log staged-artifacts/ 2>/dev/null || true
|
||||
cp repo-target/bot-changes.patch staged-artifacts/ 2>/dev/null || true
|
||||
cp repo-target/pr-description.md staged-artifacts/ 2>/dev/null || true
|
||||
cp repo-target/branch-name.txt staged-artifacts/ 2>/dev/null || true
|
||||
cp repo-target/pr-comment.md staged-artifacts/ 2>/dev/null || true
|
||||
cp repo-target/pr-number.txt staged-artifacts/ 2>/dev/null || true
|
||||
cp repo-target/issue-comment.md staged-artifacts/ 2>/dev/null || true
|
||||
cp repo-target/pr-labels.txt staged-artifacts/ 2>/dev/null || true
|
||||
|
||||
- name: 'Archive Brain Data'
|
||||
if: 'always()'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'brain-data'
|
||||
path: 'staged-artifacts/'
|
||||
path: |
|
||||
tools/gemini-cli-bot/lessons-learned.md
|
||||
tools/gemini-cli-bot/history/*.csv
|
||||
bot-changes.patch
|
||||
pr-description.md
|
||||
branch-name.txt
|
||||
pr-comment.md
|
||||
pr-number.txt
|
||||
issue-comment.md
|
||||
retention-days: 90
|
||||
|
||||
publish:
|
||||
@@ -311,7 +223,7 @@ jobs:
|
||||
steps:
|
||||
- name: 'Generate GitHub App Token 🔑'
|
||||
id: 'generate_token'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: '${{ secrets.APP_ID }}'
|
||||
@@ -322,10 +234,25 @@ jobs:
|
||||
permission-pull-requests: 'write'
|
||||
permission-issues: 'write'
|
||||
|
||||
- name: 'Determine Checkout Ref'
|
||||
id: 'determine_ref'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number }}'
|
||||
run: |
|
||||
REF="main"
|
||||
if [ -n "$ISSUE_NUMBER" ]; then
|
||||
PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "")
|
||||
if [ -n "$PR_HEAD" ]; then
|
||||
REF="$PR_HEAD"
|
||||
fi
|
||||
fi
|
||||
echo "ref=$REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ needs.reasoning.outputs.patch_base_sha || needs.reasoning.outputs.target_sha }}'
|
||||
ref: '${{ steps.determine_ref.outputs.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -336,7 +263,7 @@ jobs:
|
||||
path: '${{ runner.temp }}/brain-data/'
|
||||
|
||||
- name: 'Create or Update PR'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
|
||||
env:
|
||||
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
FALLBACK_PAT: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
@@ -348,27 +275,27 @@ jobs:
|
||||
|
||||
BRANCH_NAME="bot/productivity-updates-$(date +'%Y%m%d%H%M%S')-${{ github.run_id }}"
|
||||
if [ -f "${{ runner.temp }}/brain-data/branch-name.txt" ]; then
|
||||
FILE_BRANCH=$(cat "${{ runner.temp }}/brain-data/branch-name.txt" | tr -d '[:space:]')
|
||||
if [ -n "$FILE_BRANCH" ] && [ "$FILE_BRANCH" != "bot/" ]; then
|
||||
BRANCH_NAME="$FILE_BRANCH"
|
||||
fi
|
||||
BRANCH_NAME=$(cat "${{ runner.temp }}/brain-data/branch-name.txt")
|
||||
fi
|
||||
|
||||
if [[ ! "$BRANCH_NAME" =~ ^bot/ ]]; then
|
||||
echo "Warning: Branch name '$BRANCH_NAME' does not start with 'bot/'. Prepending 'bot/' for safety."
|
||||
BRANCH_NAME="bot/$BRANCH_NAME"
|
||||
echo "Error: Branch name '$BRANCH_NAME' does not start with 'bot/'. Safety abort."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git checkout -B "$BRANCH_NAME"
|
||||
git apply --3way --ignore-whitespace "${{ runner.temp }}/brain-data/bot-changes.patch"
|
||||
git apply "${{ runner.temp }}/brain-data/bot-changes.patch"
|
||||
git add .
|
||||
|
||||
PR_TITLE="🤖 Gemini Bot Maintenance Update"
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
|
||||
git commit -F "${{ runner.temp }}/brain-data/pr-description.md"
|
||||
PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md")
|
||||
else
|
||||
git commit -m "$PR_TITLE"
|
||||
git commit -m "🤖 Gemini Bot Productivity Optimizations"
|
||||
fi
|
||||
|
||||
PR_TITLE="🤖 Gemini Bot Productivity Optimizations"
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
|
||||
PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md")
|
||||
fi
|
||||
|
||||
if ! git push origin "$BRANCH_NAME" --force; then
|
||||
@@ -391,14 +318,6 @@ jobs:
|
||||
gh pr create --draft --title "🤖 Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$NEW_BRANCH_NAME" --base main
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-labels.txt" ]; then
|
||||
while IFS= read -r label; do
|
||||
if [ -n "$label" ]; then
|
||||
gh pr edit "$BRANCH_NAME" --add-label "$label" || true
|
||||
fi
|
||||
done < "${{ runner.temp }}/brain-data/pr-labels.txt"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: 'Post PR/Issue Comment'
|
||||
|
||||
@@ -23,7 +23,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
|
||||
@@ -33,8 +33,6 @@ jobs:
|
||||
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Lifecycle Management'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
|
||||
@@ -28,8 +28,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Log in to GitHub Container Registry'
|
||||
uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3
|
||||
|
||||
@@ -30,8 +30,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
@@ -63,16 +61,6 @@ jobs:
|
||||
const syncIssueTypes = require('./.github/scripts/sync-issue-types.cjs');
|
||||
await syncIssueTypes({ github, context, core });
|
||||
|
||||
- name: 'Find Issues with Conflicting Labels'
|
||||
if: |-
|
||||
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |-
|
||||
const findConflictingLabels = require('./.github/scripts/find-conflicting-labels.cjs');
|
||||
await findConflictingLabels({ github, context, core });
|
||||
|
||||
- name: 'Find untriaged issues'
|
||||
if: |-
|
||||
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
@@ -93,31 +81,22 @@ jobs:
|
||||
|
||||
echo '🏷️ Finding issues missing priority labels...'
|
||||
gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body > no_priority_issues.json
|
||||
--search 'is:open is:issue -label: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:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 5 --json number,title,body > no_effort_issues.json
|
||||
--search 'is:open is:issue -label: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 standard triage issues...'
|
||||
if [ ! -f conflicting_labels_issues.json ]; then echo "[]" > conflicting_labels_issues.json; fi
|
||||
jq -c -s 'add | unique_by(.number)' no_area_issues.json no_kind_issues.json no_priority_issues.json conflicting_labels_issues.json > standard_issues_to_triage.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 no_effort_issues.json no_type_issues.json > issues_to_triage.json
|
||||
|
||||
echo '📏 Deduplicating effort issues...'
|
||||
jq -c -s 'add | unique_by(.number)' no_effort_issues.json > effort_issues_to_triage.json
|
||||
|
||||
STANDARD_COUNT="$(jq 'length' standard_issues_to_triage.json)"
|
||||
EFFORT_COUNT="$(jq 'length' effort_issues_to_triage.json)"
|
||||
if [ "$STANDARD_COUNT" -gt 0 ] || [ "$EFFORT_COUNT" -gt 0 ]; then
|
||||
ISSUE_COUNT="$(jq 'length' issues_to_triage.json)"
|
||||
if [ "$ISSUE_COUNT" -gt 0 ]; then
|
||||
echo "has_issues=true" >> "${GITHUB_OUTPUT}"
|
||||
echo "has_standard_issues=$([ "$STANDARD_COUNT" -gt 0 ] && echo 'true' || echo 'false')" >> "${GITHUB_OUTPUT}"
|
||||
echo "has_effort_issues=$([ "$EFFORT_COUNT" -gt 0 ] && echo 'true' || echo 'false')" >> "${GITHUB_OUTPUT}"
|
||||
else
|
||||
echo "has_issues=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "has_standard_issues=false" >> "${GITHUB_OUTPUT}"
|
||||
echo "has_effort_issues=false" >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
echo "✅ Found ${STANDARD_COUNT} standard issues and ${EFFORT_COUNT} effort issues to triage! 🎯"
|
||||
echo "✅ Found ${ISSUE_COUNT} unique issues to triage! 🎯"
|
||||
|
||||
- name: 'Create Gemini CLI Experiments Override'
|
||||
if: |-
|
||||
@@ -150,128 +129,11 @@ jobs:
|
||||
core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`);
|
||||
return labelNames;
|
||||
|
||||
- name: 'Run Standard Triage Analysis'
|
||||
- name: 'Run Gemini Issue Analysis'
|
||||
if: |-
|
||||
steps.get_issue_from_event.outputs.has_issues == 'true' || steps.find_issues.outputs.has_standard_issues == 'true'
|
||||
steps.get_issue_from_event.outputs.has_issues == 'true' || steps.find_issues.outputs.has_issues == 'true'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
id: 'gemini_standard_issue_analysis'
|
||||
env:
|
||||
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
|
||||
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 }}'
|
||||
gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}'
|
||||
gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}'
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}'
|
||||
use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}'
|
||||
settings: |-
|
||||
{
|
||||
"maxSessionTurns": 25,
|
||||
"coreTools": [
|
||||
"run_shell_command(echo)",
|
||||
"read_file"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"target": "gcp"
|
||||
}
|
||||
}
|
||||
prompt: |-
|
||||
## Role
|
||||
|
||||
You are an issue triage assistant. Analyze issues and identify
|
||||
appropriate labels. Use the available tools to gather information;
|
||||
do not ask for information to be provided.
|
||||
|
||||
## Steps
|
||||
|
||||
1. You are only able to use the echo and read_file commands. Review the available labels in the environment variable: "${AVAILABLE_LABELS}".
|
||||
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage.
|
||||
3. Review the issue title, body and any comments provided in the JSON file.
|
||||
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, and priority/*.
|
||||
5. Label Policy:
|
||||
- If the issue already has a kind/ label, do not change it.
|
||||
- If the issue has exactly ONE priority/ label, do not change it.
|
||||
- If the issue is missing a priority/ label, OR if the issue currently has MULTIPLE priority/ labels, you must evaluate the issue's impact to determine exactly ONE priority level (priority/p0, priority/p1, priority/p2, priority/p3, or priority/unknown) based the guidelines. If you are fixing an issue with multiple priority/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
|
||||
- If the issue has exactly ONE area/ label, do not change it.
|
||||
- If the issue is missing an area/ label, OR if the issue currently has MULTIPLE area/ labels, select exactly ONE area/ label that best fits the issue. Issues MUST NOT have multiple area/ labels. If you are fixing an issue with multiple area/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
|
||||
- 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:
|
||||
```
|
||||
[
|
||||
{
|
||||
"issue_number": 123,
|
||||
"labels_to_add": ["area/core", "kind/bug", "priority/p2"],
|
||||
"labels_to_remove": ["status/need-triage"],
|
||||
"explanation": "This issue is a UI bug that needs to be addressed with medium priority."
|
||||
}
|
||||
]
|
||||
```
|
||||
If an issue cannot be classified, do not include it in the output array.
|
||||
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
|
||||
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
|
||||
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
|
||||
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
|
||||
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Output only valid JSON format
|
||||
- Do not include any explanation or additional text, just the JSON
|
||||
- Only use labels that already exist in the repository.
|
||||
- Do not add comments or modify the issue content.
|
||||
- Do not remove the following labels maintainer, help wanted or good first issue.
|
||||
- Triage only the current issue.
|
||||
- Identify exactly ONE area/ label. Do NOT assign multiple area/ labels to a single issue.
|
||||
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
|
||||
- Identify exactly ONE priority/ label. Do NOT assign multiple priority/ labels to a single issue.
|
||||
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
|
||||
|
||||
Categorization Guidelines (Priority):
|
||||
P0 - Urgent Blocking Issues:
|
||||
- Definition: Critical failures breaking core functionality for a large portion of users. Examples: CLI fails to launch globally, core commands (gemini run) crash on valid input, unhandled promise rejections on boot, critical security vulnerability.
|
||||
- Note: You must apply status/manual-triage instead of priority/p0.
|
||||
P1 - Critical but Workable:
|
||||
- Definition: Severe issues without a reasonable workaround, significantly degrading the developer experience but not globally blocking. Examples: Specific tools failing consistently (e.g., `web_search` returns 500s), persistent PTY streaming hangs, memory leaks leading to OOM after short use.
|
||||
P2 - Significant Issues:
|
||||
- Definition: Affect some workflows but a clear workaround exists, or non-critical bugs. Examples: Theme flickering, confusing error messages, minor UI misalignment, failing to read deeply nested config files correctly.
|
||||
P3 - Minor/Enhancements:
|
||||
- Definition: Trivial bugs, typos, documentation requests, or feature requests.
|
||||
|
||||
Categorization Guidelines (Kind):
|
||||
kind/bug: The issue is describing an unexpected behavior or failure in the application.
|
||||
kind/enhancement: The issue is describing a feature request or an improvement to an existing feature.
|
||||
kind/question: The issue is asking a question about how to use the CLI or about a specific feature.
|
||||
|
||||
Categorization Guidelines (Area):
|
||||
area/agent: The "brain" of the CLI. Core agent logic, model quality, tool/function calling, memory, web search, generated code quality, sub-agents.
|
||||
area/core: The fundamental CLI app. UI/UX, installation, OS compatibility, performance, command parsing, theming, flickering.
|
||||
area/documentation: Website docs, READMEs, inline help text.
|
||||
area/enterprise: Telemetry, Policy, Quota / Licensing
|
||||
area/extensions: Gemini CLI extensions capability
|
||||
area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation
|
||||
area/platform: Platform specific behavior
|
||||
area/security: Authentication, authorization, privacy, data leaks, credential storage.
|
||||
|
||||
- name: 'Stop Telemetry Collector'
|
||||
if: |-
|
||||
steps.find_issues.outputs.has_effort_issues == 'true'
|
||||
run: 'docker rm -f gemini-telemetry-collector || true'
|
||||
|
||||
- name: 'Run Effort Triage Analysis'
|
||||
if: |-
|
||||
steps.find_issues.outputs.has_effort_issues == 'true'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
id: 'gemini_effort_issue_analysis'
|
||||
id: 'gemini_issue_analysis'
|
||||
env:
|
||||
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
@@ -304,30 +166,57 @@ jobs:
|
||||
prompt: |-
|
||||
## Role
|
||||
|
||||
You are an expert software architect. Analyze the provided GitHub issues and assign the correct `effort/*` label based on the codebase complexity.
|
||||
You are an issue triage assistant. Analyze issues and identify
|
||||
appropriate labels. Use the available tools to gather information;
|
||||
do not ask for information to be provided.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Use the read_file tool to read "effort_issues_to_triage.json".
|
||||
2. For each issue in the array:
|
||||
- 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.
|
||||
3. Output a JSON array of objects, each containing the issue number and the effort label to add, along with an explanation and 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:
|
||||
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/*, 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. 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": ["effort/small"],
|
||||
"explanation": "This is a simple logic fix.",
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
]
|
||||
```
|
||||
If an issue cannot be classified, do not include it in the output array.
|
||||
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
|
||||
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
|
||||
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
|
||||
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
|
||||
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Output only valid JSON format
|
||||
- Do not include any explanation or additional text, just the JSON
|
||||
- Triage only the current issue.
|
||||
- Only use labels that already exist in the repository.
|
||||
- Do not add comments or modify the issue content.
|
||||
- Do not remove the following labels maintainer, help wanted or good first issue.
|
||||
- Triage only the current issue.
|
||||
- Identify only one area/ label.
|
||||
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
|
||||
- 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):
|
||||
@@ -383,29 +272,13 @@ jobs:
|
||||
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
|
||||
- When users report that they dont expect the model to change those would be categorized as feature requests.
|
||||
|
||||
- name: 'Apply Standard Labels to Issues'
|
||||
- name: 'Apply Labels to Issues'
|
||||
if: |-
|
||||
${{ steps.gemini_standard_issue_analysis.outcome == 'success' &&
|
||||
steps.gemini_standard_issue_analysis.outputs.summary != '[]' &&
|
||||
steps.gemini_standard_issue_analysis.outputs.summary != '' }}
|
||||
${{ steps.gemini_issue_analysis.outcome == 'success' &&
|
||||
steps.gemini_issue_analysis.outputs.summary != '[]' }}
|
||||
env:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
LABELS_OUTPUT: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |-
|
||||
const applyLabels = require('./.github/scripts/apply-issue-labels.cjs');
|
||||
await applyLabels({ github, context, core });
|
||||
|
||||
- name: 'Apply Effort Labels to Issues'
|
||||
if: |-
|
||||
${{ steps.gemini_effort_issue_analysis.outcome == 'success' &&
|
||||
steps.gemini_effort_issue_analysis.outputs.summary != '[]' &&
|
||||
steps.gemini_effort_issue_analysis.outputs.summary != '' }}
|
||||
env:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
LABELS_OUTPUT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
|
||||
LABELS_OUTPUT: '${{ steps.gemini_issue_analysis.outputs.summary }}'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
|
||||
@@ -21,8 +21,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Generate GitHub App Token'
|
||||
id: 'generate_token'
|
||||
|
||||
@@ -19,8 +19,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
@@ -43,8 +41,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
|
||||
@@ -17,8 +17,6 @@ jobs:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Link Checker'
|
||||
id: 'lychee'
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
|
||||
@@ -16,8 +16,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
|
||||
@@ -44,7 +44,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ github.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
|
||||
|
||||
@@ -65,13 +65,11 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Checkout Release Code'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
path: 'release'
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -50,13 +50,11 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Checkout Release Code'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
path: 'release'
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -31,7 +31,6 @@ jobs:
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# The user-level skills need to be available to the workflow
|
||||
fetch-depth: 0
|
||||
ref: 'main'
|
||||
|
||||
@@ -17,7 +17,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 'Slash Command Dispatch'
|
||||
|
||||
@@ -54,7 +54,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
|
||||
@@ -64,7 +64,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: "${{ github.event.inputs.workflow_ref || 'main' }}"
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -53,14 +53,12 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: 'Checkout Release Code'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.release_ref }}'
|
||||
path: 'release'
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -55,7 +55,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
@@ -172,13 +171,11 @@ jobs:
|
||||
- name: 'Checkout Ref'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
|
||||
- name: 'Checkout correct SHA'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ matrix.sha }}'
|
||||
path: 'release'
|
||||
fetch-depth: 0
|
||||
@@ -219,13 +216,11 @@ jobs:
|
||||
- name: 'Checkout Ref'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
|
||||
- name: 'Checkout correct SHA'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ needs.calculate-versions.outputs.PREVIEW_SHA }}'
|
||||
path: 'release'
|
||||
fetch-depth: 0
|
||||
@@ -293,13 +288,11 @@ jobs:
|
||||
- name: 'Checkout Ref'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
|
||||
- name: 'Checkout correct SHA'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ needs.calculate-versions.outputs.STABLE_SHA }}'
|
||||
path: 'release'
|
||||
fetch-depth: 0
|
||||
@@ -367,7 +360,6 @@ jobs:
|
||||
- name: 'Checkout Ref'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
|
||||
@@ -52,7 +52,6 @@ jobs:
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.ref }}'
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ jobs:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
- name: 'Push'
|
||||
|
||||
@@ -32,7 +32,6 @@ jobs:
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: 'Install Dependencies'
|
||||
run: 'npm ci'
|
||||
- name: 'Build bundle'
|
||||
|
||||
@@ -34,8 +34,6 @@ jobs:
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Optimize Windows Performance'
|
||||
if: "matrix.os == 'windows-latest'"
|
||||
|
||||
@@ -44,8 +44,6 @@ jobs:
|
||||
shell: 'bash'
|
||||
run: 'echo "${{ toJSON(vars) }}"'
|
||||
- uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: 'Verify release'
|
||||
uses: './.github/actions/verify-release'
|
||||
with:
|
||||
|
||||
@@ -43,13 +43,9 @@
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "CLI: Run Current File",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["--import", "tsx"],
|
||||
"name": "Launch Program",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"program": "${file}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"outFiles": ["${workspaceFolder}/**/*.js"]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1043,28 +1043,6 @@ 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', () => {
|
||||
|
||||
@@ -1105,11 +1105,7 @@ export async function loadCliConfig(
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
// 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,
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
|
||||
@@ -1300,8 +1300,7 @@ export async function inferInstallMetadata(
|
||||
source.startsWith('git@') ||
|
||||
source.startsWith('sso://') ||
|
||||
source.startsWith('github:') ||
|
||||
source.startsWith('gitlab:') ||
|
||||
source.startsWith('ssh://')
|
||||
source.startsWith('gitlab:')
|
||||
) {
|
||||
return {
|
||||
source,
|
||||
|
||||
@@ -1189,39 +1189,6 @@ describe('resolveSessionId', () => {
|
||||
expect(sessionId).toBe('new-id');
|
||||
expect(resumedSessionData).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exit with FATAL_INPUT_ERROR when explicit resume session is missing', async () => {
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
resolveSession: vi
|
||||
.fn()
|
||||
.mockRejectedValue(SessionError.noSessionsFound()),
|
||||
}) as unknown as InstanceType<typeof SessionSelector>,
|
||||
);
|
||||
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
try {
|
||||
await resolveSessionId('explicit-session-id');
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('Error resuming session:'),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR);
|
||||
|
||||
emitFeedbackSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini.tsx main function exit codes', () => {
|
||||
|
||||
@@ -85,11 +85,7 @@ import { validateAuthMethod } from './config/auth.js';
|
||||
import { runAcpClient } from './acp/acpStdioTransport.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import {
|
||||
RESUME_LATEST,
|
||||
SessionError,
|
||||
SessionSelector,
|
||||
} from './utils/sessionUtils.js';
|
||||
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
|
||||
|
||||
import { relaunchOnExitCode } from './utils/relaunch.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
@@ -313,10 +309,8 @@ export async function resolveSessionId(
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND') {
|
||||
if (resumeArg === RESUME_LATEST) {
|
||||
coreEvents.emitFeedback('warning', error.message);
|
||||
return { sessionId: createSessionId() };
|
||||
}
|
||||
coreEvents.emitFeedback('warning', error.message);
|
||||
return { sessionId: createSessionId() };
|
||||
}
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
|
||||
@@ -68,9 +68,6 @@ export async function runNonInteractive(
|
||||
): Promise<void> {
|
||||
const useAgentSession = params.config.getAgentSessionNoninteractiveEnabled();
|
||||
if (useAgentSession) {
|
||||
debugLogger.debug(
|
||||
'[ADK] Running non-interactive mode with ADK agent session',
|
||||
);
|
||||
return runNonInteractiveAgentSession(params);
|
||||
}
|
||||
|
||||
|
||||
@@ -1267,42 +1267,6 @@ 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
|
||||
|
||||
@@ -497,6 +497,16 @@ 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) {
|
||||
@@ -539,6 +549,12 @@ 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(
|
||||
|
||||
@@ -5332,34 +5332,6 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal buffer rendering', () => {
|
||||
it('does not clip the last char of a visual line whose width equals inputWidth', async () => {
|
||||
const fullLine = '1234567890'; // 10 chars, exactly props.inputWidth
|
||||
props.inputWidth = 10;
|
||||
props.suggestionsWidth = 10;
|
||||
vi.spyOn(props.config, 'getUseTerminalBuffer').mockReturnValue(true);
|
||||
mockBuffer.text = fullLine;
|
||||
mockBuffer.lines = [fullLine];
|
||||
mockBuffer.allVisualLines = [fullLine];
|
||||
mockBuffer.viewportVisualLines = [fullLine];
|
||||
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||
mockBuffer.visualToTransformedMap = [0];
|
||||
mockBuffer.transformationsByLine = [[]];
|
||||
mockBuffer.cursor = [0, fullLine.length];
|
||||
mockBuffer.visualCursor = [0, fullLine.length];
|
||||
|
||||
const { lastFrame, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{ uiActions },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clean(lastFrame())).toContain(fullLine);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clean(str: string | undefined): string {
|
||||
|
||||
@@ -93,8 +93,6 @@ import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
|
||||
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
const SCROLLBAR_GUTTER_WIDTH = 1;
|
||||
|
||||
/**
|
||||
* Returns if the terminal can be trusted to handle paste events atomically
|
||||
* rather than potentially sending multiple paste events separated by line
|
||||
@@ -1870,7 +1868,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
? `line-${item.absoluteVisualIdx}`
|
||||
: `ghost-${item.index}`
|
||||
}
|
||||
width={inputWidth + SCROLLBAR_GUTTER_WIDTH}
|
||||
width={inputWidth}
|
||||
backgroundColor={listBackgroundColor}
|
||||
containerHeight={Math.min(
|
||||
buffer.viewportHeight,
|
||||
|
||||
@@ -562,13 +562,6 @@ export const useSessionBrowserInput = (
|
||||
state.setActiveIndex(0);
|
||||
state.setScrollOffset(0);
|
||||
return true;
|
||||
} else if (key.name === 'enter') {
|
||||
const selectedSession =
|
||||
state.filteredAndSortedSessions[state.activeIndex];
|
||||
if (selectedSession && !selectedSession.isCurrentSession) {
|
||||
onResumeSession(selectedSession);
|
||||
}
|
||||
return true;
|
||||
} else if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
|
||||
@@ -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 [Shell]?');
|
||||
expect(output).toContain('Allow execution of [ls]?');
|
||||
expect(output).toMatchSnapshot();
|
||||
|
||||
unmount();
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="71" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
<text x="0" y="19" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs"> > hello </text>
|
||||
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -60,12 +60,6 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 2`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello
|
||||
────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello 👍 world
|
||||
@@ -174,27 +168,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> [Pasted Text: 10 lines]
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> [Pasted Text: 10 lines]
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
|
||||
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> [Pasted Text: 10 lines]
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello
|
||||
|
||||
@@ -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="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
|
||||
<text x="252" y="308" fill="#ffffff" textLength="459" lengthAdjust="spacingAndGlyphs">? </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="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="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">[Shell]</text>
|
||||
<text x="252" y="563" fill="#ffffff" textLength="459" lengthAdjust="spacingAndGlyphs">? </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="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 [Shell]? │
|
||||
│ Allow execution of [echo]? │
|
||||
│ │
|
||||
│ ● 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 [Shell]? │
|
||||
│ Allow execution of [echo]? │
|
||||
│ │
|
||||
│ ● 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 [Shell]? │
|
||||
│ Allow execution of [ls]? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
|
||||
import {
|
||||
Kind,
|
||||
CoreToolCallStatus,
|
||||
SubagentState,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
@@ -31,12 +27,12 @@ describe('<SubagentGroupDisplay />', () => {
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'api-monitor',
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'act-1',
|
||||
type: 'tool_call',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
content: '',
|
||||
displayName: 'Action Required',
|
||||
description: 'Verify server is running',
|
||||
@@ -54,13 +50,13 @@ describe('<SubagentGroupDisplay />', () => {
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'db-manager',
|
||||
state: SubagentState.COMPLETED,
|
||||
state: 'completed',
|
||||
result: 'Database schema validated',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'act-2',
|
||||
type: 'thought',
|
||||
status: SubagentState.COMPLETED,
|
||||
status: 'completed',
|
||||
content: 'Database schema validated',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
isSubagentProgress,
|
||||
checkExhaustive,
|
||||
type SubagentActivityItem,
|
||||
SubagentState,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SubagentProgressDisplay,
|
||||
@@ -67,13 +66,13 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
|
||||
const singleAgent = toolCalls[0].resultDisplay;
|
||||
if (isSubagentProgress(singleAgent)) {
|
||||
switch (singleAgent.state) {
|
||||
case SubagentState.COMPLETED:
|
||||
case 'completed':
|
||||
headerText = 'Agent Completed';
|
||||
break;
|
||||
case SubagentState.CANCELLED:
|
||||
case 'cancelled':
|
||||
headerText = 'Agent Cancelled';
|
||||
break;
|
||||
case SubagentState.ERROR:
|
||||
case 'error':
|
||||
headerText = 'Agent Error';
|
||||
break;
|
||||
default:
|
||||
@@ -89,8 +88,8 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
|
||||
for (const tc of toolCalls) {
|
||||
const progress = tc.resultDisplay;
|
||||
if (isSubagentProgress(progress)) {
|
||||
if (progress.state === SubagentState.COMPLETED) completedCount++;
|
||||
else if (progress.state === SubagentState.RUNNING) runningCount++;
|
||||
if (progress.state === 'completed') completedCount++;
|
||||
else if (progress.state === 'running') runningCount++;
|
||||
} else {
|
||||
// It hasn't emitted progress yet, but it is "running"
|
||||
runningCount++;
|
||||
@@ -201,7 +200,7 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
|
||||
let content = 'Starting...';
|
||||
let formattedArgs: string | undefined;
|
||||
|
||||
if (progress.state === SubagentState.COMPLETED) {
|
||||
if (progress.state === 'completed') {
|
||||
if (
|
||||
progress.terminateReason &&
|
||||
progress.terminateReason !== 'GOAL'
|
||||
@@ -224,18 +223,18 @@ export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
|
||||
}
|
||||
|
||||
const displayArgs =
|
||||
progress.state === SubagentState.COMPLETED ? '' : formattedArgs;
|
||||
progress.state === 'completed' ? '' : formattedArgs;
|
||||
|
||||
const renderStatusIcon = () => {
|
||||
const state = progress.state ?? SubagentState.RUNNING;
|
||||
const state = progress.state ?? 'running';
|
||||
switch (state) {
|
||||
case SubagentState.RUNNING:
|
||||
case 'running':
|
||||
return <Text color={theme.text.primary}>!</Text>;
|
||||
case SubagentState.COMPLETED:
|
||||
case 'completed':
|
||||
return <Text color={theme.status.success}>✓</Text>;
|
||||
case SubagentState.CANCELLED:
|
||||
case 'cancelled':
|
||||
return <Text color={theme.status.warning}>ℹ</Text>;
|
||||
case SubagentState.ERROR:
|
||||
case 'error':
|
||||
return <Text color={theme.status.error}>✗</Text>;
|
||||
default:
|
||||
return checkExhaustive(state);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { SubagentHistoryMessage } from './SubagentHistoryMessage.js';
|
||||
import type { HistoryItemSubagent } from '../../types.js';
|
||||
import { SubagentState } from '@google/gemini-cli-core';
|
||||
|
||||
describe('SubagentHistoryMessage', () => {
|
||||
const mockItem: HistoryItemSubagent = {
|
||||
@@ -19,19 +18,19 @@ describe('SubagentHistoryMessage', () => {
|
||||
id: '1',
|
||||
type: 'thought',
|
||||
content: 'Thinking about the problem',
|
||||
status: SubagentState.COMPLETED,
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'tool_call',
|
||||
content: 'Calling search_web',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'tool_call',
|
||||
content: 'Calling read_file fail',
|
||||
status: SubagentState.ERROR,
|
||||
status: 'error',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { render, cleanup } from '../../../test-utils/render.js';
|
||||
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
|
||||
import { type SubagentProgress, SubagentState } from '@google/gemini-cli-core';
|
||||
import type { SubagentProgress } from '@google/gemini-cli-core';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
describe('<SubagentProgressDisplay />', () => {
|
||||
@@ -25,7 +25,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: '{"command": "echo hello", "description": "Say hello"}',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -48,7 +48,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
displayName: 'RunShellCommand',
|
||||
description: 'Executing echo hello',
|
||||
args: '{"command": "echo hello"}',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -69,7 +69,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: '{"command": "echo hello"}',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -90,7 +90,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
type: 'tool_call',
|
||||
content: 'write_file',
|
||||
args: '{"file_path": "/tmp/test.txt", "content": "foo"}',
|
||||
status: SubagentState.COMPLETED,
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -113,7 +113,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: JSON.stringify({ description: longDesc }),
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -133,7 +133,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
id: '5',
|
||||
type: 'thought',
|
||||
content: 'Thinking about life',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -149,7 +149,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
recentActivity: [],
|
||||
state: SubagentState.CANCELLED,
|
||||
state: 'cancelled',
|
||||
};
|
||||
|
||||
const { lastFrame } = await render(
|
||||
@@ -167,7 +167,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
id: '6',
|
||||
type: 'thought',
|
||||
content: 'Request cancelled.',
|
||||
status: SubagentState.ERROR,
|
||||
status: 'error',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -188,7 +188,7 @@ describe('<SubagentProgressDisplay />', () => {
|
||||
type: 'tool_call',
|
||||
content: 'run_shell_command',
|
||||
args: '{"command": "echo hello"}',
|
||||
status: SubagentState.ERROR,
|
||||
status: 'error',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,10 +9,9 @@ import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import Spinner from 'ink-spinner';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import {
|
||||
type SubagentProgress,
|
||||
type SubagentActivityItem,
|
||||
SubagentState,
|
||||
import type {
|
||||
SubagentProgress,
|
||||
SubagentActivityItem,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { TOOL_STATUS } from '../../constants.js';
|
||||
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
|
||||
@@ -63,13 +62,13 @@ export const SubagentProgressDisplay: React.FC<
|
||||
let headerText: string | undefined;
|
||||
let headerColor = theme.text.secondary;
|
||||
|
||||
if (progress.state === SubagentState.CANCELLED) {
|
||||
if (progress.state === 'cancelled') {
|
||||
headerText = `Subagent ${progress.agentName} was cancelled.`;
|
||||
headerColor = theme.status.warning;
|
||||
} else if (progress.state === SubagentState.ERROR) {
|
||||
} else if (progress.state === 'error') {
|
||||
headerText = `Subagent ${progress.agentName} failed.`;
|
||||
headerColor = theme.status.error;
|
||||
} else if (progress.state === SubagentState.COMPLETED) {
|
||||
} else if (progress.state === 'completed') {
|
||||
headerText = `Subagent ${progress.agentName} completed.`;
|
||||
headerColor = theme.status.success;
|
||||
} else {
|
||||
@@ -108,13 +107,13 @@ export const SubagentProgressDisplay: React.FC<
|
||||
);
|
||||
} else if (item.type === 'tool_call') {
|
||||
const statusSymbol =
|
||||
item.status === SubagentState.RUNNING ? (
|
||||
item.status === 'running' ? (
|
||||
<Spinner type="dots" />
|
||||
) : item.status === SubagentState.COMPLETED ? (
|
||||
) : item.status === 'completed' ? (
|
||||
<Text color={theme.status.success}>
|
||||
{TOOL_STATUS.SUCCESS}
|
||||
</Text>
|
||||
) : item.status === SubagentState.CANCELLED ? (
|
||||
) : item.status === 'cancelled' ? (
|
||||
<Text color={theme.status.warning} bold>
|
||||
{TOOL_STATUS.CANCELED}
|
||||
</Text>
|
||||
@@ -136,7 +135,7 @@ export const SubagentProgressDisplay: React.FC<
|
||||
<Text
|
||||
bold
|
||||
color={theme.text.primary}
|
||||
strikethrough={item.status === SubagentState.CANCELLED}
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{item.displayName || item.content}
|
||||
</Text>
|
||||
@@ -145,9 +144,7 @@ export const SubagentProgressDisplay: React.FC<
|
||||
<Text
|
||||
color={theme.text.secondary}
|
||||
wrap="truncate"
|
||||
strikethrough={
|
||||
item.status === SubagentState.CANCELLED
|
||||
}
|
||||
strikethrough={item.status === 'cancelled'}
|
||||
>
|
||||
{displayArgs}
|
||||
</Text>
|
||||
@@ -173,7 +170,7 @@ export const SubagentProgressDisplay: React.FC<
|
||||
)}
|
||||
<MarkdownDisplay
|
||||
text={safeJsonToMarkdown(progress.result)}
|
||||
isPending={progress.state !== SubagentState.COMPLETED}
|
||||
isPending={progress.state !== 'completed'}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -275,108 +275,6 @@ 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,13 +629,18 @@ export const ToolConfirmationMessage: React.FC<
|
||||
);
|
||||
}
|
||||
} else if (confirmationDetails.type === 'sandbox_expansion') {
|
||||
const { additionalPermissions, command } = confirmationDetails;
|
||||
const { additionalPermissions, command, rootCommand } =
|
||||
confirmationDetails;
|
||||
const readPaths = additionalPermissions?.fileSystem?.read || [];
|
||||
const writePaths = additionalPermissions?.fileSystem?.write || [];
|
||||
const network = additionalPermissions?.network;
|
||||
const isShell = isShellTool(toolName);
|
||||
|
||||
const commandNames = isShell ? 'Shell' : 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(', ');
|
||||
question = '';
|
||||
|
||||
bodyContent = (
|
||||
@@ -725,7 +730,13 @@ export const ToolConfirmationMessage: React.FC<
|
||||
);
|
||||
}
|
||||
|
||||
const commandNames = isShell ? 'Shell' : toolName;
|
||||
const commandNames = Array.from(
|
||||
new Set(
|
||||
commandsToDisplay
|
||||
.map((cmd) => cmd.trim().split(/\s+/)[0])
|
||||
.filter(Boolean),
|
||||
),
|
||||
).join(', ');
|
||||
|
||||
const allowQuestion = (
|
||||
<Text>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ApprovalMode,
|
||||
WRITE_FILE_DISPLAY_NAME,
|
||||
Kind,
|
||||
SubagentState,
|
||||
} from '@google/gemini-cli-core';
|
||||
import os from 'node:os';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
@@ -77,7 +76,7 @@ describe('ToolGroupMessage Regression Tests', () => {
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
recentActivity: [],
|
||||
},
|
||||
}),
|
||||
@@ -113,7 +112,7 @@ describe('ToolGroupMessage Regression Tests', () => {
|
||||
resultDisplay: {
|
||||
isSubagentProgress: true,
|
||||
agentName: 'TestAgent',
|
||||
state: SubagentState.COMPLETED,
|
||||
state: 'completed',
|
||||
recentActivity: [],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -32,12 +32,10 @@ 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 ||
|
||||
normalized === 'shell'
|
||||
name === SHELL_TOOL_NAME
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ exports[`ToolConfirmationMessage Redirection > should display redirection warnin
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ echo "hello" > test.txt │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Allow execution of [Shell]?
|
||||
Allow execution of [echo]?
|
||||
Redirection detected. To auto-accept, press Shift+Tab
|
||||
|
||||
● 1. Allow once
|
||||
|
||||
@@ -133,9 +133,7 @@
|
||||
<text x="63" y="546" fill="#cdcd00" textLength="81" lengthAdjust="spacingAndGlyphs">"Line 50"</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="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>
|
||||
<text x="0" y="580" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of [echo]? </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,9 +24,7 @@
|
||||
<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="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>
|
||||
<text x="0" y="104" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Allow execution of [echo]? </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.2 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -94,7 +94,7 @@ exports[`ToolConfirmationMessage > height allocation and layout > should expand
|
||||
│ echo "Line 49" │
|
||||
│ echo "Line 50" │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Allow execution of [Shell]?
|
||||
Allow execution of [echo]?
|
||||
|
||||
● 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 [Shell]?
|
||||
Allow execution of [echo, ls, whoami]?
|
||||
|
||||
● 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 [Shell]?
|
||||
Allow execution of [echo]?
|
||||
|
||||
● 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 [Shell]?
|
||||
Allow execution of [echo]?
|
||||
|
||||
● 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 [Shell]?
|
||||
Allow execution of [echo]?
|
||||
|
||||
● 1. Allow once
|
||||
2. Allow for this session
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ROOT_SCHEDULER_ID,
|
||||
CoreToolCallStatus,
|
||||
type WaitingToolCall,
|
||||
SubagentState,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
|
||||
|
||||
@@ -631,7 +630,7 @@ describe('useToolScheduler', () => {
|
||||
id: '1',
|
||||
type: 'thought',
|
||||
content: 'Thinking...',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -649,7 +648,7 @@ describe('useToolScheduler', () => {
|
||||
id: '2',
|
||||
type: 'tool_call',
|
||||
content: 'Calling tool',
|
||||
status: SubagentState.COMPLETED,
|
||||
status: 'completed',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -698,7 +697,7 @@ describe('useToolScheduler', () => {
|
||||
id: '1',
|
||||
type: 'thought',
|
||||
content: 'Thinking...',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -717,7 +716,7 @@ describe('useToolScheduler', () => {
|
||||
id: '1',
|
||||
type: 'thought',
|
||||
content: 'Thinking... Done!',
|
||||
status: SubagentState.COMPLETED,
|
||||
status: 'completed',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -727,8 +726,6 @@ describe('useToolScheduler', () => {
|
||||
expect(result.current[0][0].subagentHistory![0].content).toBe(
|
||||
'Thinking... Done!',
|
||||
);
|
||||
expect(result.current[0][0].subagentHistory![0].status).toBe(
|
||||
SubagentState.COMPLETED,
|
||||
);
|
||||
expect(result.current[0][0].subagentHistory![0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -616,120 +616,6 @@ describe('SessionSelector', () => {
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe(mainSessionId);
|
||||
});
|
||||
|
||||
it('should list legacy session JSON without timestamps (regression #18593)', async () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session = {
|
||||
sessionId,
|
||||
projectHash: 'test-hash',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Legacy session message',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const filePath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
|
||||
);
|
||||
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
|
||||
const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z');
|
||||
await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp);
|
||||
|
||||
const sessionSelector = new SessionSelector(storage);
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
expect(sessions.length).toBe(1);
|
||||
expect(sessions[0].id).toBe(sessionId);
|
||||
expect(sessions[0].startTime).toBe(fallbackTimestamp.toISOString());
|
||||
expect(sessions[0].lastUpdated).toBe(fallbackTimestamp.toISOString());
|
||||
});
|
||||
|
||||
it('should resolve legacy session JSON without timestamps by UUID (regression #18593)', async () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session = {
|
||||
sessionId,
|
||||
projectHash: 'test-hash',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Legacy session message',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const filePath = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
|
||||
);
|
||||
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
|
||||
const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z');
|
||||
await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp);
|
||||
|
||||
const sessionSelector = new SessionSelector(storage);
|
||||
const result = await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
expect(result.sessionData.sessionId).toBe(sessionId);
|
||||
expect(result.sessionData.startTime).toBe(fallbackTimestamp.toISOString());
|
||||
expect(result.sessionData.lastUpdated).toBe(
|
||||
fallbackTimestamp.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw INVALID_SESSION_IDENTIFIER for a UUID that does not exist on disk at all', async () => {
|
||||
const existingSessionId = randomUUID();
|
||||
const nonExistentId = randomUUID();
|
||||
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session = {
|
||||
sessionId: existingSessionId,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Hello',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${existingSessionId.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(storage);
|
||||
|
||||
await expect(sessionSelector.findSession(nonExistentId)).rejects.toSatisfy(
|
||||
(error) => {
|
||||
expect(error).toBeInstanceOf(SessionError);
|
||||
expect((error as SessionError).code).toBe('INVALID_SESSION_IDENTIFIER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFirstUserMessage', () => {
|
||||
|
||||
@@ -270,23 +270,15 @@ export const getAllSessionFiles = async (
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!content.sessionId) {
|
||||
if (
|
||||
!content.sessionId ||
|
||||
!content.startTime ||
|
||||
!content.lastUpdated
|
||||
) {
|
||||
// Missing required fields - treat as corrupted
|
||||
return { fileName: file, sessionInfo: null };
|
||||
}
|
||||
|
||||
const fileTimestamp =
|
||||
!content.startTime || !content.lastUpdated
|
||||
? (
|
||||
await fs.stat(filePath).catch(() => undefined)
|
||||
)?.mtime.toISOString()
|
||||
: undefined;
|
||||
const fallbackTimestamp = fileTimestamp ?? new Date().toISOString();
|
||||
const startTime =
|
||||
content.startTime || content.lastUpdated || fallbackTimestamp;
|
||||
const lastUpdated =
|
||||
content.lastUpdated || content.startTime || fallbackTimestamp;
|
||||
|
||||
// Skip sessions that only contain system messages (info, error, warning)
|
||||
if (!content.hasUserOrAssistantMessage) {
|
||||
return { fileName: file, sessionInfo: null };
|
||||
@@ -327,8 +319,8 @@ export const getAllSessionFiles = async (
|
||||
id: content.sessionId,
|
||||
file: file.replace(/\.jsonl?$/, ''),
|
||||
fileName: file,
|
||||
startTime,
|
||||
lastUpdated,
|
||||
startTime: content.startTime,
|
||||
lastUpdated: content.lastUpdated,
|
||||
messageCount: content.messageCount ?? content.messages.length,
|
||||
displayName: content.summary
|
||||
? stripUnsafeCharacters(content.summary)
|
||||
@@ -554,17 +546,12 @@ export class SessionSelector {
|
||||
if (!sessionData) {
|
||||
throw new Error('Failed to load session data');
|
||||
}
|
||||
const normalizedSessionData = {
|
||||
...sessionData,
|
||||
startTime: sessionData.startTime || sessionInfo.startTime,
|
||||
lastUpdated: sessionData.lastUpdated || sessionInfo.lastUpdated,
|
||||
};
|
||||
|
||||
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
|
||||
|
||||
return {
|
||||
sessionPath,
|
||||
sessionData: normalizedSessionData,
|
||||
sessionData,
|
||||
displayInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
AgentInterface,
|
||||
} from '@a2a-js/sdk';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
import { type SubagentActivityItem, SubagentState } from './types.js';
|
||||
import type { SubagentActivityItem } from './types.js';
|
||||
|
||||
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
|
||||
|
||||
@@ -143,7 +143,7 @@ export class A2AResultReassembler {
|
||||
id: 'auth-required',
|
||||
type: 'thought',
|
||||
content: AUTH_REQUIRED_MSG,
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export class A2AResultReassembler {
|
||||
id: `msg-${index}`,
|
||||
type: 'thought',
|
||||
content: msg.trim(),
|
||||
status: SubagentState.COMPLETED,
|
||||
status: 'completed',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,7 +161,7 @@ export class A2AResultReassembler {
|
||||
id: 'pending',
|
||||
type: 'thought',
|
||||
content: 'Working...',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
type SubagentActivityItem,
|
||||
AgentTerminateMode,
|
||||
isToolActivityError,
|
||||
SubagentState,
|
||||
} from '../types.js';
|
||||
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
|
||||
import { createBrowserAgentDefinition } from './browserAgentFactory.js';
|
||||
@@ -124,7 +123,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
isSubagentProgress: true,
|
||||
agentName: this.agentName,
|
||||
recentActivity: [],
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
};
|
||||
updateOutput(initialProgress);
|
||||
}
|
||||
@@ -138,7 +137,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
id: randomUUID(),
|
||||
type: 'thought',
|
||||
content: sanitizedMsg,
|
||||
status: SubagentState.COMPLETED,
|
||||
status: 'completed',
|
||||
});
|
||||
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
|
||||
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
|
||||
@@ -147,7 +146,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
isSubagentProgress: true,
|
||||
agentName: this.agentName,
|
||||
recentActivity: [...recentActivity],
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
} as SubagentProgress);
|
||||
}
|
||||
: undefined;
|
||||
@@ -176,7 +175,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
if (
|
||||
lastItem &&
|
||||
lastItem.type === 'thought' &&
|
||||
lastItem.status === SubagentState.RUNNING
|
||||
lastItem.status === 'running'
|
||||
) {
|
||||
lastItem.content = sanitizeThoughtContent(text);
|
||||
} else {
|
||||
@@ -184,7 +183,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
id: randomUUID(),
|
||||
type: 'thought',
|
||||
content: sanitizeThoughtContent(text),
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
updated = true;
|
||||
@@ -211,7 +210,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
displayName,
|
||||
description,
|
||||
args,
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
});
|
||||
updated = true;
|
||||
break;
|
||||
@@ -228,11 +227,9 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
callId != null &&
|
||||
recentActivity[i].id === callId &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
recentActivity[i].status === 'running'
|
||||
) {
|
||||
recentActivity[i].status = isError
|
||||
? SubagentState.ERROR
|
||||
: SubagentState.COMPLETED;
|
||||
recentActivity[i].status = isError ? 'error' : 'completed';
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
@@ -245,9 +242,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
const callId = activity.data['callId']
|
||||
? String(activity.data['callId'])
|
||||
: undefined;
|
||||
const newStatus = isCancellation
|
||||
? SubagentState.CANCELLED
|
||||
: SubagentState.ERROR;
|
||||
const newStatus = isCancellation ? 'cancelled' : 'error';
|
||||
|
||||
if (callId) {
|
||||
// Mark the specific tool as error/cancelled
|
||||
@@ -255,7 +250,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].id === callId &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
recentActivity[i].status === 'running'
|
||||
) {
|
||||
recentActivity[i].status = newStatus;
|
||||
updated = true;
|
||||
@@ -265,10 +260,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
} else {
|
||||
// No specific tool — mark ALL running tool_call items
|
||||
for (const item of recentActivity) {
|
||||
if (
|
||||
item.type === 'tool_call' &&
|
||||
item.status === SubagentState.RUNNING
|
||||
) {
|
||||
if (item.type === 'tool_call' && item.status === 'running') {
|
||||
item.status = newStatus;
|
||||
updated = true;
|
||||
}
|
||||
@@ -301,7 +293,7 @@ export class BrowserAgentInvocation extends BaseToolInvocation<
|
||||
isSubagentProgress: true,
|
||||
agentName: this.agentName,
|
||||
recentActivity: [...recentActivity],
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
};
|
||||
updateOutput(progress);
|
||||
}
|
||||
@@ -338,13 +330,13 @@ ${output.result}`;
|
||||
// GOAL = agent completed its task normally.
|
||||
// ABORTED = user cancelled.
|
||||
// Others (ERROR, MAX_TURNS, ERROR_NO_COMPLETE_TASK_CALL) = error.
|
||||
let progressState: SubagentState;
|
||||
let progressState: SubagentProgress['state'];
|
||||
if (output.terminate_reason === AgentTerminateMode.ABORTED) {
|
||||
progressState = SubagentState.CANCELLED;
|
||||
progressState = 'cancelled';
|
||||
} else if (output.terminate_reason === AgentTerminateMode.GOAL) {
|
||||
progressState = SubagentState.COMPLETED;
|
||||
progressState = 'completed';
|
||||
} else {
|
||||
progressState = SubagentState.ERROR;
|
||||
progressState = 'error';
|
||||
}
|
||||
|
||||
const progress: SubagentProgress = {
|
||||
@@ -374,8 +366,8 @@ ${output.result}`;
|
||||
|
||||
// Mark any running items as error/cancelled
|
||||
for (const item of recentActivity) {
|
||||
if (item.status === SubagentState.RUNNING) {
|
||||
item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
|
||||
if (item.status === 'running') {
|
||||
item.status = isAbort ? 'cancelled' : 'error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +375,7 @@ ${output.result}`;
|
||||
isSubagentProgress: true,
|
||||
agentName: this.agentName,
|
||||
recentActivity: [...recentActivity],
|
||||
state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR,
|
||||
state: isAbort ? 'cancelled' : 'error',
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
|
||||
@@ -4199,49 +4199,6 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(memoryPart).toBeDefined();
|
||||
expect(memoryPart?.text).toContain(mockMemory);
|
||||
});
|
||||
|
||||
it('should omit extension context from session memory when disabled by the agent', async () => {
|
||||
const definition = createTestDefinition();
|
||||
definition.includeExtensionContext = false;
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
const getSessionMemorySpy = vi
|
||||
.spyOn(mockConfig, 'getSessionMemory')
|
||||
.mockImplementation(
|
||||
(options?: { includeExtensionContext?: boolean }) =>
|
||||
options?.includeExtensionContext === false
|
||||
? '<loaded_context>\n<project_context>\nProject memory rule\n</project_context>\n</loaded_context>'
|
||||
: '<loaded_context>\n<extension_context>\nExtension memory rule\n</extension_context>\n<project_context>\nProject memory rule\n</project_context>\n</loaded_context>',
|
||||
);
|
||||
vi.spyOn(mockConfig, 'isJitContextEnabled').mockReturnValue(true);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: COMPLETE_TASK_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'call1',
|
||||
},
|
||||
]);
|
||||
|
||||
await executor.run({ goal: 'test' }, signal);
|
||||
|
||||
expect(getSessionMemorySpy).toHaveBeenCalledWith({
|
||||
includeExtensionContext: false,
|
||||
});
|
||||
const { message } = getMockMessageParams(0);
|
||||
const parts = message as Part[];
|
||||
const memoryPart = parts.find((p) =>
|
||||
p.text?.includes('<loaded_context>'),
|
||||
);
|
||||
|
||||
expect(memoryPart?.text).toContain('Project memory rule');
|
||||
expect(memoryPart?.text).not.toContain('<extension_context>');
|
||||
expect(memoryPart?.text).not.toContain('Extension memory rule');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -640,19 +640,10 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
);
|
||||
const formattedInitialHints = formatUserHintsForModel(initialHints);
|
||||
|
||||
// Inject loaded memory files. Some background agents opt out of
|
||||
// extension memory while still retaining project session context.
|
||||
let environmentMemory: string;
|
||||
if (this.context.config.isJitContextEnabled?.()) {
|
||||
environmentMemory =
|
||||
this.definition.includeExtensionContext === false
|
||||
? this.context.config.getSessionMemory({
|
||||
includeExtensionContext: false,
|
||||
})
|
||||
: this.context.config.getSessionMemory();
|
||||
} else {
|
||||
environmentMemory = this.context.config.getEnvironmentMemory();
|
||||
}
|
||||
// Inject loaded memory files (JIT + extension/project memory)
|
||||
const environmentMemory = this.context.config.isJitContextEnabled?.()
|
||||
? this.context.config.getSessionMemory()
|
||||
: this.context.config.getEnvironmentMemory();
|
||||
|
||||
const initialParts: Part[] = [];
|
||||
if (environmentMemory) {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type SubagentProgress,
|
||||
SubagentActivityErrorType,
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
SubagentState,
|
||||
} from './types.js';
|
||||
import { LocalSubagentInvocation } from './local-invocation.js';
|
||||
import { LocalAgentExecutor } from './local-executor.js';
|
||||
@@ -216,7 +215,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
]);
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe(SubagentState.COMPLETED);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Analysis complete.');
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
|
||||
});
|
||||
@@ -235,7 +234,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe(SubagentState.COMPLETED);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Partial progress...');
|
||||
expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT);
|
||||
});
|
||||
@@ -341,7 +340,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
content: 'Error: Failed',
|
||||
status: SubagentState.ERROR,
|
||||
status: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -377,7 +376,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect.objectContaining({
|
||||
type: 'tool_call',
|
||||
content: 'ls',
|
||||
status: SubagentState.ERROR,
|
||||
status: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -419,7 +418,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect.objectContaining({
|
||||
type: 'tool_call',
|
||||
content: 'ls',
|
||||
status: SubagentState.CANCELLED,
|
||||
status: 'cancelled',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -444,7 +443,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect(result.error).toBeUndefined();
|
||||
const display = result.returnDisplay as SubagentProgress;
|
||||
expect(display.isSubagentProgress).toBe(true);
|
||||
expect(display.state).toBe(SubagentState.COMPLETED);
|
||||
expect(display.state).toBe('completed');
|
||||
expect(display.result).toBe('Done');
|
||||
});
|
||||
|
||||
@@ -467,7 +466,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect.objectContaining({
|
||||
type: 'thought',
|
||||
content: `Error: ${error.message}`,
|
||||
status: SubagentState.ERROR,
|
||||
status: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -489,7 +488,7 @@ describe('LocalSubagentInvocation', () => {
|
||||
expect(display.recentActivity).toContainEqual(
|
||||
expect.objectContaining({
|
||||
content: `Error: ${creationError.message}`,
|
||||
status: SubagentState.ERROR,
|
||||
status: 'error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
SUBAGENT_REJECTED_ERROR_PREFIX,
|
||||
SUBAGENT_CANCELLED_ERROR_MESSAGE,
|
||||
isToolActivityError,
|
||||
SubagentState,
|
||||
} from './types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { z } from 'zod';
|
||||
@@ -118,7 +117,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [],
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
};
|
||||
updateOutput(initialProgress);
|
||||
}
|
||||
@@ -138,7 +137,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
if (
|
||||
lastItem &&
|
||||
lastItem.type === 'thought' &&
|
||||
lastItem.status === SubagentState.RUNNING
|
||||
lastItem.status === 'running'
|
||||
) {
|
||||
lastItem.content = sanitizeThoughtContent(text);
|
||||
} else {
|
||||
@@ -146,7 +145,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
id: randomUUID(),
|
||||
type: 'thought',
|
||||
content: sanitizeThoughtContent(text),
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
updated = true;
|
||||
@@ -175,7 +174,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
displayName,
|
||||
description,
|
||||
args,
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
});
|
||||
updated = true;
|
||||
|
||||
@@ -194,11 +193,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].content === name &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
recentActivity[i].status === 'running'
|
||||
) {
|
||||
recentActivity[i].status = isError
|
||||
? SubagentState.ERROR
|
||||
: SubagentState.COMPLETED;
|
||||
recentActivity[i].status = isError ? 'error' : 'completed';
|
||||
updated = true;
|
||||
|
||||
this.publishActivity(recentActivity[i]);
|
||||
@@ -227,9 +224,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].content === toolName &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
recentActivity[i].status === 'running'
|
||||
) {
|
||||
recentActivity[i].status = SubagentState.CANCELLED;
|
||||
recentActivity[i].status = 'cancelled';
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
@@ -240,9 +237,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
if (
|
||||
recentActivity[i].type === 'tool_call' &&
|
||||
recentActivity[i].content === toolName &&
|
||||
recentActivity[i].status === SubagentState.RUNNING
|
||||
recentActivity[i].status === 'running'
|
||||
) {
|
||||
recentActivity[i].status = SubagentState.ERROR;
|
||||
recentActivity[i].status = 'error';
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
@@ -256,10 +253,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
isCancellation || isRejection
|
||||
? sanitizedError
|
||||
: `Error: ${sanitizedError}`,
|
||||
status:
|
||||
isCancellation || isRejection
|
||||
? SubagentState.CANCELLED
|
||||
: SubagentState.ERROR,
|
||||
status: isCancellation || isRejection ? 'cancelled' : 'error',
|
||||
});
|
||||
updated = true;
|
||||
break;
|
||||
@@ -273,7 +267,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity], // Copy to avoid mutation issues
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
};
|
||||
|
||||
updateOutput(progress);
|
||||
@@ -293,7 +287,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity],
|
||||
state: SubagentState.CANCELLED,
|
||||
state: 'cancelled',
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
@@ -309,7 +303,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity],
|
||||
state: SubagentState.COMPLETED,
|
||||
state: 'completed',
|
||||
result: output.result,
|
||||
terminateReason: output.terminate_reason,
|
||||
};
|
||||
@@ -340,8 +334,8 @@ ${output.result}`;
|
||||
|
||||
// Mark any running items as error/cancelled
|
||||
for (const item of recentActivity) {
|
||||
if (item.status === SubagentState.RUNNING) {
|
||||
item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
|
||||
if (item.status === 'running') {
|
||||
item.status = isAbort ? 'cancelled' : 'error';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,12 +343,12 @@ ${output.result}`;
|
||||
// But only if it's NOT an abort, or if we want to show "Cancelled" as a thought
|
||||
if (!isAbort) {
|
||||
const lastActivity = recentActivity[recentActivity.length - 1];
|
||||
if (!lastActivity || lastActivity.status !== SubagentState.ERROR) {
|
||||
if (!lastActivity || lastActivity.status !== 'error') {
|
||||
recentActivity.push({
|
||||
id: randomUUID(),
|
||||
type: 'thought',
|
||||
content: `Error: ${errorMessage}`,
|
||||
status: SubagentState.ERROR,
|
||||
status: 'error',
|
||||
});
|
||||
// Maintain size limit
|
||||
// No limit on UI events sent via bus
|
||||
@@ -365,7 +359,7 @@ ${output.result}`;
|
||||
isSubagentProgress: true,
|
||||
agentName: this.definition.name,
|
||||
recentActivity: [...recentActivity],
|
||||
state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR,
|
||||
state: isAbort ? 'cancelled' : 'error',
|
||||
};
|
||||
|
||||
if (updateOutput) {
|
||||
|
||||
@@ -20,11 +20,7 @@ import {
|
||||
type A2AClientManager,
|
||||
} from './a2a-client-manager.js';
|
||||
|
||||
import {
|
||||
type RemoteAgentDefinition,
|
||||
type SubagentProgress,
|
||||
SubagentState,
|
||||
} from './types.js';
|
||||
import type { RemoteAgentDefinition, SubagentProgress } from './types.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import type { A2AAuthProvider } from './auth-provider/types.js';
|
||||
@@ -272,9 +268,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
state: SubagentState.ERROR,
|
||||
});
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
"Failed to create auth provider for agent 'test-agent'",
|
||||
);
|
||||
@@ -467,7 +461,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
recentActivity: expect.arrayContaining([
|
||||
expect.objectContaining({ content: 'Working...' }),
|
||||
]),
|
||||
@@ -476,7 +470,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: SubagentState.COMPLETED,
|
||||
state: 'completed',
|
||||
result: 'HelloHello World',
|
||||
}),
|
||||
);
|
||||
@@ -514,9 +508,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
state: SubagentState.ERROR,
|
||||
});
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
@@ -541,7 +533,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
state: SubagentState.ERROR,
|
||||
state: 'error',
|
||||
result: expect.stringContaining('Network error'),
|
||||
});
|
||||
});
|
||||
@@ -624,7 +616,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
recentActivity: expect.arrayContaining([
|
||||
expect.objectContaining({ content: 'Working...' }),
|
||||
]),
|
||||
@@ -633,7 +625,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: SubagentState.COMPLETED,
|
||||
state: 'completed',
|
||||
result: 'Thinking...Final Answer',
|
||||
}),
|
||||
);
|
||||
@@ -701,7 +693,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
recentActivity: expect.arrayContaining([
|
||||
expect.objectContaining({ content: 'Working...' }),
|
||||
]),
|
||||
@@ -710,7 +702,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
expect(updateOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSubagentProgress: true,
|
||||
state: SubagentState.COMPLETED,
|
||||
state: 'completed',
|
||||
result: 'Generating...\n\nArtifact (Result):\nPart 1 Part 2',
|
||||
}),
|
||||
);
|
||||
@@ -768,9 +760,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
state: SubagentState.ERROR,
|
||||
});
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
a2aError.userMessage,
|
||||
);
|
||||
@@ -792,9 +782,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
state: SubagentState.ERROR,
|
||||
});
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
expect((result.returnDisplay as SubagentProgress).result).toContain(
|
||||
'Error calling remote agent: something unexpected',
|
||||
);
|
||||
@@ -825,9 +813,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
abortSignal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
state: SubagentState.ERROR,
|
||||
});
|
||||
expect(result.returnDisplay).toMatchObject({ state: 'error' });
|
||||
// Should contain both the partial output and the error message
|
||||
expect(result.returnDisplay).toMatchObject({
|
||||
result: expect.stringContaining('Partial response'),
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
type RemoteAgentDefinition,
|
||||
type AgentInputs,
|
||||
type SubagentProgress,
|
||||
SubagentState,
|
||||
getAgentCardLoadOptions,
|
||||
getRemoteAgentTargetUrl,
|
||||
} from './types.js';
|
||||
@@ -139,13 +138,13 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
updateOutput({
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
recentActivity: [
|
||||
{
|
||||
id: 'pending',
|
||||
type: 'thought',
|
||||
content: 'Working...',
|
||||
status: SubagentState.RUNNING,
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -194,7 +193,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
updateOutput({
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
recentActivity: reassembler.toActivityItems(),
|
||||
result: reassembler.toString(),
|
||||
});
|
||||
@@ -226,7 +225,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
const finalProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: SubagentState.COMPLETED,
|
||||
state: 'completed',
|
||||
result: finalOutput,
|
||||
recentActivity: reassembler.toActivityItems(),
|
||||
};
|
||||
@@ -250,7 +249,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
const errorProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName,
|
||||
state: SubagentState.ERROR,
|
||||
state: 'error',
|
||||
result: fullDisplay,
|
||||
recentActivity: reassembler.toActivityItems(),
|
||||
};
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
DEFAULT_QUERY_STRING,
|
||||
type RemoteAgentDefinition,
|
||||
type SubagentProgress,
|
||||
SubagentState,
|
||||
getRemoteAgentTargetUrl,
|
||||
getAgentCardLoadOptions,
|
||||
} from './types.js';
|
||||
@@ -234,7 +233,7 @@ class RemoteSubagentProtocol implements AgentProtocol {
|
||||
this._latestProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this._agentName,
|
||||
state: SubagentState.RUNNING,
|
||||
state: 'running',
|
||||
recentActivity: reassembler.toActivityItems(),
|
||||
result: currentText,
|
||||
};
|
||||
@@ -260,7 +259,7 @@ class RemoteSubagentProtocol implements AgentProtocol {
|
||||
const finalProgress: SubagentProgress = {
|
||||
isSubagentProgress: true,
|
||||
agentName: this._agentName,
|
||||
state: SubagentState.COMPLETED,
|
||||
state: 'completed',
|
||||
result: finalOutput,
|
||||
recentActivity: reassembler.toActivityItems(),
|
||||
};
|
||||
|
||||
@@ -37,7 +37,6 @@ describe('SkillExtractionAgent', () => {
|
||||
expect(agent.modelConfig.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
expect(agent.memoryInboxAccess).toBe(true);
|
||||
expect(agent.autoMemoryExtractionWriteAccess).toBe(true);
|
||||
expect(agent.includeExtensionContext).toBe(false);
|
||||
expect(agent.toolConfig?.tools).toEqual(
|
||||
expect.arrayContaining([
|
||||
READ_FILE_TOOL_NAME,
|
||||
|
||||
@@ -415,7 +415,6 @@ export const SkillExtractionAgent = (
|
||||
},
|
||||
memoryInboxAccess: true,
|
||||
autoMemoryExtractionWriteAccess: true,
|
||||
includeExtensionContext: false,
|
||||
toolConfig: {
|
||||
tools: [
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
|
||||
@@ -88,13 +88,6 @@ export interface SubagentActivityEvent {
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export enum SubagentState {
|
||||
RUNNING = 'running',
|
||||
COMPLETED = 'completed',
|
||||
ERROR = 'error',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export interface SubagentActivityItem {
|
||||
id: string;
|
||||
type: 'thought' | 'tool_call';
|
||||
@@ -102,14 +95,14 @@ export interface SubagentActivityItem {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
args?: string;
|
||||
status: SubagentState;
|
||||
status: 'running' | 'completed' | 'error' | 'cancelled';
|
||||
}
|
||||
|
||||
export interface SubagentProgress {
|
||||
isSubagentProgress: true;
|
||||
agentName: string;
|
||||
recentActivity: SubagentActivityItem[];
|
||||
state?: SubagentState;
|
||||
state?: 'running' | 'completed' | 'error' | 'cancelled';
|
||||
result?: string;
|
||||
terminateReason?: AgentTerminateMode;
|
||||
}
|
||||
@@ -251,12 +244,6 @@ export interface LocalAgentDefinition<
|
||||
*/
|
||||
autoMemoryExtractionWriteAccess?: boolean;
|
||||
|
||||
/**
|
||||
* Controls whether extension memory is injected into this agent's initial
|
||||
* session context when JIT context is enabled. Defaults to true.
|
||||
*/
|
||||
includeExtensionContext?: boolean;
|
||||
|
||||
/**
|
||||
* Optional inline MCP servers for this agent.
|
||||
*/
|
||||
|
||||
@@ -168,46 +168,4 @@ describe('ModelAvailabilityService', () => {
|
||||
reason: 'quota',
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefix normalization', () => {
|
||||
it('treats prefixed and non-prefixed models as identical when marking terminal', () => {
|
||||
service.markTerminal('models/gemini-3.1-pro-preview', 'quota');
|
||||
|
||||
// Checking the non-prefixed version should show it as unavailable
|
||||
expect(service.snapshot('gemini-3.1-pro-preview')).toEqual({
|
||||
available: false,
|
||||
reason: 'quota',
|
||||
});
|
||||
|
||||
// Checking the prefixed version should also show it as unavailable
|
||||
expect(service.snapshot('models/gemini-3.1-pro-preview')).toEqual({
|
||||
available: false,
|
||||
reason: 'quota',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats prefixed and non-prefixed models as identical when selecting', () => {
|
||||
service.markTerminal('gemini-3-flash-preview', 'quota');
|
||||
|
||||
// Attempting to select the prefixed version should skip it because the base is exhausted
|
||||
const result = service.selectFirstAvailable([
|
||||
'models/gemini-3-flash-preview',
|
||||
'gemini-3.1-pro-preview',
|
||||
]);
|
||||
|
||||
expect(result.selectedModel).toBe('gemini-3.1-pro-preview');
|
||||
expect(result.skipped).toEqual([
|
||||
{ model: 'gemini-3-flash-preview', reason: 'quota' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats prefixed and non-prefixed models as identical when marking healthy', () => {
|
||||
service.markTerminal('gemini-3-flash-preview', 'quota');
|
||||
service.markHealthy('models/gemini-3-flash-preview');
|
||||
|
||||
expect(service.snapshot('gemini-3-flash-preview')).toEqual({
|
||||
available: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,26 +39,21 @@ export interface ModelSelectionResult {
|
||||
}>;
|
||||
}
|
||||
|
||||
import { normalizeModelId } from '../utils/modelUtils.js';
|
||||
|
||||
export class ModelAvailabilityService {
|
||||
private readonly health = new Map<ModelId, HealthState>();
|
||||
|
||||
markTerminal(modelId: ModelId, reason: TerminalUnavailabilityReason) {
|
||||
const model = normalizeModelId(modelId);
|
||||
markTerminal(model: ModelId, reason: TerminalUnavailabilityReason) {
|
||||
this.setState(model, {
|
||||
status: 'terminal',
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
markHealthy(modelId: ModelId) {
|
||||
const model = normalizeModelId(modelId);
|
||||
markHealthy(model: ModelId) {
|
||||
this.clearState(model);
|
||||
}
|
||||
|
||||
markRetryOncePerTurn(modelId: ModelId, attempts: number = 1) {
|
||||
const model = normalizeModelId(modelId);
|
||||
markRetryOncePerTurn(model: ModelId, attempts: number = 1) {
|
||||
const currentState = this.health.get(model);
|
||||
// Do not override a terminal failure with a transient one.
|
||||
if (currentState?.status === 'terminal') {
|
||||
@@ -80,16 +75,14 @@ export class ModelAvailabilityService {
|
||||
});
|
||||
}
|
||||
|
||||
consumeStickyAttempt(modelId: ModelId) {
|
||||
const model = normalizeModelId(modelId);
|
||||
consumeStickyAttempt(model: ModelId) {
|
||||
const state = this.health.get(model);
|
||||
if (state?.status === 'sticky_retry') {
|
||||
this.setState(model, { ...state, consumed: true });
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(modelId: ModelId): ModelAvailabilitySnapshot {
|
||||
const model = normalizeModelId(modelId);
|
||||
snapshot(model: ModelId): ModelAvailabilitySnapshot {
|
||||
const state = this.health.get(model);
|
||||
|
||||
if (!state) {
|
||||
@@ -107,11 +100,10 @@ export class ModelAvailabilityService {
|
||||
return { available: true };
|
||||
}
|
||||
|
||||
selectFirstAvailable(modelIds: ModelId[]): ModelSelectionResult {
|
||||
selectFirstAvailable(models: ModelId[]): ModelSelectionResult {
|
||||
const skipped: ModelSelectionResult['skipped'] = [];
|
||||
|
||||
for (const modelId of modelIds) {
|
||||
const model = normalizeModelId(modelId);
|
||||
for (const model of models) {
|
||||
const snapshot = this.snapshot(model);
|
||||
if (snapshot.available) {
|
||||
const state = this.health.get(model);
|
||||
|
||||
@@ -96,11 +96,10 @@ describe('policyHelpers', () => {
|
||||
|
||||
it('starts chain from preferredModel when model is "auto"', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => 'auto',
|
||||
getModel: () => DEFAULT_GEMINI_MODEL_AUTO,
|
||||
});
|
||||
const chain = resolvePolicyChain(config, 'gemini-2.5-flash');
|
||||
// Due to Gemini 2.x wrapsAround, the chain will contain both flash and pro
|
||||
expect(chain.length).toBeGreaterThanOrEqual(1);
|
||||
expect(chain).toHaveLength(1);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
isGemini3Model,
|
||||
resolveModel,
|
||||
} from '../config/models.js';
|
||||
import { normalizeModelId } from '../utils/modelUtils.js';
|
||||
import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
@@ -42,13 +41,9 @@ export function resolvePolicyChain(
|
||||
preferredModel?: string,
|
||||
wrapsAround: boolean = false,
|
||||
): ModelPolicyChain {
|
||||
const normalizedPreferredModel = preferredModel
|
||||
? normalizeModelId(preferredModel)
|
||||
: undefined;
|
||||
const modelFromConfig = normalizeModelId(
|
||||
normalizedPreferredModel ?? config.getActiveModel?.() ?? config.getModel(),
|
||||
);
|
||||
const configuredModel = normalizeModelId(config.getModel());
|
||||
const modelFromConfig =
|
||||
preferredModel ?? config.getActiveModel?.() ?? config.getModel();
|
||||
const configuredModel = config.getModel();
|
||||
|
||||
let chain: ModelPolicyChain | undefined;
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
@@ -57,29 +52,19 @@ export function resolvePolicyChain(
|
||||
const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false;
|
||||
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
|
||||
|
||||
const resolvedModel = normalizeModelId(
|
||||
resolveModel(
|
||||
modelFromConfig,
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
),
|
||||
const resolvedModel = resolveModel(
|
||||
modelFromConfig,
|
||||
useGemini31,
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
config,
|
||||
);
|
||||
const isAutoPreferred = normalizedPreferredModel
|
||||
? isAutoModel(normalizedPreferredModel, config)
|
||||
const isAutoPreferred = preferredModel
|
||||
? isAutoModel(preferredModel, config)
|
||||
: false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel, config);
|
||||
|
||||
// We always wrap around for Gemini 3 chains to ensure maximum availability
|
||||
// between models in the same family (e.g. fallback to Pro if Flash is exhausted).
|
||||
const effectiveWrapsAround =
|
||||
wrapsAround ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured ||
|
||||
isGemini3Model(resolvedModel, config);
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (config.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const context = {
|
||||
@@ -91,7 +76,7 @@ export function resolvePolicyChain(
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = config.modelConfigService.resolveChain('lite', context);
|
||||
} else if (
|
||||
isGemini3Model(normalizeModelId(resolvedModel), config) ||
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
@@ -111,7 +96,7 @@ export function resolvePolicyChain(
|
||||
const previewEnabled =
|
||||
hasAccessToPreview &&
|
||||
(isGemini3Model(resolvedModel, config) ||
|
||||
normalizedPreferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO);
|
||||
const autoPrefix = isAutoSelection ? 'auto-' : '';
|
||||
const chainKey = previewEnabled ? 'preview' : 'default';
|
||||
@@ -125,7 +110,7 @@ export function resolvePolicyChain(
|
||||
// No matching modelChains found, default to single model chain
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
chain = applyDynamicSlicing(chain, resolvedModel, effectiveWrapsAround);
|
||||
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
} else {
|
||||
// --- LEGACY PATH ---
|
||||
|
||||
@@ -140,7 +125,7 @@ export function resolvePolicyChain(
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
normalizedPreferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
@@ -165,7 +150,7 @@ export function resolvePolicyChain(
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
chain = applyDynamicSlicing(chain, resolvedModel, effectiveWrapsAround);
|
||||
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
|
||||
}
|
||||
// Apply Unified Silent Injection for Plan Mode with defensive checks
|
||||
if (config?.getApprovalMode?.() === ApprovalMode.PLAN) {
|
||||
@@ -186,9 +171,8 @@ function applyDynamicSlicing(
|
||||
resolvedModel: string,
|
||||
wrapsAround: boolean,
|
||||
): ModelPolicyChain {
|
||||
const normalizedResolved = normalizeModelId(resolvedModel);
|
||||
const activeIndex = chain.findIndex(
|
||||
(policy) => normalizeModelId(policy.model) === normalizedResolved,
|
||||
(policy) => policy.model === resolvedModel,
|
||||
);
|
||||
if (activeIndex !== -1) {
|
||||
return wrapsAround
|
||||
@@ -216,10 +200,7 @@ export function buildFallbackPolicyContext(
|
||||
failedPolicy?: ModelPolicy;
|
||||
candidates: ModelPolicy[];
|
||||
} {
|
||||
const normalizedFailed = normalizeModelId(failedModel);
|
||||
const index = chain.findIndex(
|
||||
(policy) => normalizeModelId(policy.model) === normalizedFailed,
|
||||
);
|
||||
const index = chain.findIndex((policy) => policy.model === failedModel);
|
||||
if (index === -1) {
|
||||
return { failedPolicy: undefined, candidates: chain };
|
||||
}
|
||||
|
||||
@@ -60,10 +60,6 @@ async function triggerPostAuthCallbacks(tokens: Credentials) {
|
||||
refresh_token: tokens.refresh_token ?? undefined, // Ensure null is not passed
|
||||
type: 'authorized_user',
|
||||
client_email: userAccountManager.getCachedGoogleAccount() ?? undefined,
|
||||
quota_project_id:
|
||||
process.env['GOOGLE_CLOUD_QUOTA_PROJECT'] ||
|
||||
process.env['GOOGLE_CLOUD_PROJECT'] ||
|
||||
process.env['GOOGLE_CLOUD_PROJECT_ID'],
|
||||
};
|
||||
|
||||
// Execute all registered post-authentication callbacks.
|
||||
|
||||
@@ -100,6 +100,9 @@ vi.mock('../tools/tool-registry', () => {
|
||||
ToolRegistryMock.prototype.getTool = vi.fn();
|
||||
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
|
||||
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
|
||||
ToolRegistryMock.prototype.getToolLimitReport = vi
|
||||
.fn()
|
||||
.mockReturnValue({ totalActive: 0, allowedCount: 0, ignoredTools: [] });
|
||||
return { ToolRegistry: ToolRegistryMock };
|
||||
});
|
||||
|
||||
@@ -3525,16 +3528,6 @@ describe('Config JIT Initialization', () => {
|
||||
expect(sessionMemory).toContain('</project_context>');
|
||||
expect(sessionMemory).toContain('</loaded_context>');
|
||||
|
||||
const sessionMemoryWithoutExtension = config.getSessionMemory({
|
||||
includeExtensionContext: false,
|
||||
});
|
||||
expect(sessionMemoryWithoutExtension).toContain('<loaded_context>');
|
||||
expect(sessionMemoryWithoutExtension).not.toContain('<extension_context>');
|
||||
expect(sessionMemoryWithoutExtension).not.toContain('Extension Memory');
|
||||
expect(sessionMemoryWithoutExtension).toContain('<project_context>');
|
||||
expect(sessionMemoryWithoutExtension).toContain('Environment Memory');
|
||||
expect(sessionMemoryWithoutExtension).toContain('</loaded_context>');
|
||||
|
||||
// Verify state update (delegated to MemoryContextManager)
|
||||
expect(config.getGeminiMdFileCount()).toBe(1);
|
||||
expect(config.getGeminiMdFilePaths()).toEqual(['/path/to/GEMINI.md']);
|
||||
@@ -3756,8 +3749,6 @@ describe('Config JIT Initialization', () => {
|
||||
expect(config.isPathAllowed(privateExtractionPatch)).toBe(true);
|
||||
expect(config.validatePathAccess(privateExtractionPatch)).toBeNull();
|
||||
expect(config.isPathAllowed(globalExtractionPatch)).toBe(true);
|
||||
// Writes (the default checkType for isPathAllowed) remain restricted
|
||||
// to the canonical extraction.patch filenames.
|
||||
expect(
|
||||
config.isPathAllowed(path.join(inboxRoot, 'private', 'other.patch')),
|
||||
).toBe(false);
|
||||
@@ -3766,49 +3757,9 @@ describe('Config JIT Initialization', () => {
|
||||
path.join(inboxRoot, 'private', 'nested', 'extraction.patch'),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Reads are broadened to the .inbox/{private,global}/ subtree so the
|
||||
// extractor can list and inspect prior patches before consolidating.
|
||||
const privateOtherPatch = path.join(
|
||||
inboxRoot,
|
||||
'private',
|
||||
'other.patch',
|
||||
);
|
||||
const globalLeftover = path.join(inboxRoot, 'global', 'topic-a.patch');
|
||||
const nestedReadPath = path.join(
|
||||
inboxRoot,
|
||||
'private',
|
||||
'nested',
|
||||
'extraction.patch',
|
||||
);
|
||||
expect(config.validatePathAccess(privateOtherPatch, 'read')).toBeNull();
|
||||
expect(config.validatePathAccess(globalLeftover, 'read')).toBeNull();
|
||||
expect(config.validatePathAccess(nestedReadPath, 'read')).toBeNull();
|
||||
expect(config.validatePathAccess(inboxRoot, 'read')).toBeNull();
|
||||
expect(
|
||||
config.validatePathAccess(path.join(inboxRoot, 'private'), 'read'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
config.validatePathAccess(path.join(inboxRoot, 'global'), 'read'),
|
||||
).toBeNull();
|
||||
|
||||
// Writes to the same broadened paths are still rejected.
|
||||
expect(config.validatePathAccess(privateOtherPatch)).toContain(
|
||||
'Path not in workspace',
|
||||
);
|
||||
expect(config.validatePathAccess(nestedReadPath)).toContain(
|
||||
'Path not in workspace',
|
||||
);
|
||||
});
|
||||
|
||||
expect(config.isPathAllowed(privateExtractionPatch)).toBe(false);
|
||||
// Outside the scope, reads of inbox files are denied again.
|
||||
expect(
|
||||
config.validatePathAccess(
|
||||
path.join(inboxRoot, 'private', 'other.patch'),
|
||||
'read',
|
||||
),
|
||||
).toContain('Path not in workspace');
|
||||
});
|
||||
|
||||
it('should restrict scoped auto-memory extraction writes to generated artifacts', () => {
|
||||
@@ -4331,3 +4282,81 @@ describe('ADKSettings', () => {
|
||||
expect(config.getAgentSessionNoninteractiveEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config Tool Limit Warning', () => {
|
||||
const localParams: ConfigParameters = {
|
||||
sessionId: 'test-session-id',
|
||||
targetDir: '/test/dir',
|
||||
debugMode: false,
|
||||
model: 'test-model',
|
||||
cwd: '/tmp',
|
||||
};
|
||||
|
||||
it('should emit warning feedback when tools are ignored', () => {
|
||||
const config = new Config(localParams);
|
||||
|
||||
const mockReport = {
|
||||
totalActive: 520,
|
||||
allowedCount: 512,
|
||||
ignoredTools: ['mcp_server_tool-1', 'mcp_server_tool-2'],
|
||||
};
|
||||
|
||||
const mockToolRegistry = {
|
||||
getToolLimitReport: vi.fn().mockReturnValue(mockReport),
|
||||
};
|
||||
(config as unknown as { _toolRegistry: unknown })._toolRegistry =
|
||||
mockToolRegistry;
|
||||
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
|
||||
config.checkAndWarnToolLimit();
|
||||
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledOnce();
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('Tool limit exceeded'),
|
||||
);
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining('mcp_server_tool-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should deduplicate/suppress warnings if the ignored count has not changed', () => {
|
||||
const config = new Config(localParams);
|
||||
|
||||
const mockReport = {
|
||||
totalActive: 520,
|
||||
allowedCount: 512,
|
||||
ignoredTools: ['mcp_server_tool-1', 'mcp_server_tool-2'],
|
||||
};
|
||||
|
||||
const mockToolRegistry = {
|
||||
getToolLimitReport: vi.fn().mockReturnValue(mockReport),
|
||||
};
|
||||
(config as unknown as { _toolRegistry: unknown })._toolRegistry =
|
||||
mockToolRegistry;
|
||||
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
|
||||
config.checkAndWarnToolLimit();
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
config.checkAndWarnToolLimit();
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const newMockReport = {
|
||||
totalActive: 521,
|
||||
allowedCount: 512,
|
||||
ignoredTools: [
|
||||
'mcp_server_tool-1',
|
||||
'mcp_server_tool-2',
|
||||
'mcp_server_tool-3',
|
||||
],
|
||||
};
|
||||
mockToolRegistry.getToolLimitReport.mockReturnValue(newMockReport);
|
||||
|
||||
config.checkAndWarnToolLimit();
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -898,6 +898,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private initialized = false;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private mcpInitializationPromise: Promise<void> | null = null;
|
||||
private lastIgnoredToolsCount = 0;
|
||||
readonly storage: Storage;
|
||||
private readonly fileExclusions: FileExclusions;
|
||||
private readonly eventEmitter?: EventEmitter;
|
||||
@@ -1510,6 +1511,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
debugLogger.error('Error initializing MCP clients:', result.reason);
|
||||
}
|
||||
}
|
||||
this.checkAndWarnToolLimit();
|
||||
});
|
||||
|
||||
if (!this.interactive || this.acpMode) {
|
||||
@@ -2463,6 +2465,25 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.userMemory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any tools were ignored due to the 512 limit and emits a warning feedback event if changed.
|
||||
*/
|
||||
checkAndWarnToolLimit(): void {
|
||||
if (!this._toolRegistry) {
|
||||
return;
|
||||
}
|
||||
const report = this._toolRegistry.getToolLimitReport();
|
||||
if (report.ignoredTools.length > 0) {
|
||||
if (report.ignoredTools.length !== this.lastIgnoredToolsCount) {
|
||||
this.lastIgnoredToolsCount = report.ignoredTools.length;
|
||||
const message = `⚠️ Tool limit exceeded: Maximum supported tool count is 512. The first 512 tools have been registered (built-in tools prioritized, then discovered and MCP tools). The following ${report.ignoredTools.length} tool(s) are being ignored to prevent API errors: ${report.ignoredTools.join(', ')}.`;
|
||||
coreEvents.emitFeedback('warning', message);
|
||||
}
|
||||
} else {
|
||||
this.lastIgnoredToolsCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the MCP context, including memory, tools, and system instructions.
|
||||
*/
|
||||
@@ -2479,6 +2500,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
await this._geminiClient.setTools();
|
||||
this._geminiClient.updateSystemInstruction();
|
||||
}
|
||||
this.checkAndWarnToolLimit();
|
||||
}
|
||||
|
||||
setUserMemory(newUserMemory: string | HierarchicalMemory): void {
|
||||
@@ -2511,15 +2533,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* user message when JIT is enabled. Returns empty string when JIT is
|
||||
* disabled (Tier 2 memory is already in the system instruction).
|
||||
*/
|
||||
getSessionMemory(options?: { includeExtensionContext?: boolean }): string {
|
||||
getSessionMemory(): string {
|
||||
if (!this.experimentalJitContext || !this.memoryContextManager) {
|
||||
return '';
|
||||
}
|
||||
const sections: string[] = [];
|
||||
const includeExtensionContext = options?.includeExtensionContext ?? true;
|
||||
const extension = includeExtensionContext
|
||||
? this.memoryContextManager.getExtensionMemory()
|
||||
: '';
|
||||
const extension = this.memoryContextManager.getExtensionMemory();
|
||||
const project = this.memoryContextManager.getEnvironmentMemory();
|
||||
if (extension?.trim()) {
|
||||
sections.push(
|
||||
@@ -3091,49 +3110,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
absolutePath: string,
|
||||
resolvedPath: string,
|
||||
inboxRoot: string,
|
||||
checkType: 'read' | 'write' = 'write',
|
||||
): boolean {
|
||||
if (!hasScopedMemoryInboxAccess()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedPath = path.resolve(absolutePath);
|
||||
const resolvedMemoryRoot = resolveToRealPath(
|
||||
this.storage.getProjectMemoryTempDir(),
|
||||
);
|
||||
|
||||
// Reads: allow the inbox root and the per-kind subtrees so the extraction
|
||||
// agent can list/inspect prior patches (including non-canonical filenames
|
||||
// left over from older runs) before deciding how to rewrite the canonical
|
||||
// extraction.patch. Writes still flow through the strict canonical-path
|
||||
// check below so the inbox cannot be backdoored with arbitrary files.
|
||||
if (checkType === 'read') {
|
||||
const resolvedInboxRoot = resolveToRealPath(inboxRoot);
|
||||
const normalizedInboxRoot = path.resolve(inboxRoot);
|
||||
if (
|
||||
resolvedPath === resolvedInboxRoot ||
|
||||
normalizedPath === normalizedInboxRoot
|
||||
) {
|
||||
return isSubpath(resolvedMemoryRoot, resolvedPath);
|
||||
}
|
||||
|
||||
for (const kind of ['private', 'global'] as const) {
|
||||
const kindRoot = path.join(inboxRoot, kind);
|
||||
const resolvedKindRoot = resolveToRealPath(kindRoot);
|
||||
const normalizedKindRoot = path.resolve(kindRoot);
|
||||
if (
|
||||
resolvedPath === resolvedKindRoot ||
|
||||
normalizedPath === normalizedKindRoot ||
|
||||
isSubpath(resolvedKindRoot, resolvedPath) ||
|
||||
isSubpath(normalizedKindRoot, normalizedPath)
|
||||
) {
|
||||
return isSubpath(resolvedMemoryRoot, resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const isCanonicalPatchPath = (['private', 'global'] as const).some(
|
||||
(kind) =>
|
||||
normalizedPath === path.resolve(inboxRoot, kind, 'extraction.patch'),
|
||||
@@ -3142,6 +3124,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedMemoryRoot = resolveToRealPath(
|
||||
this.storage.getProjectMemoryTempDir(),
|
||||
);
|
||||
return isSubpath(resolvedMemoryRoot, resolvedPath);
|
||||
}
|
||||
|
||||
@@ -3185,9 +3170,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* the auto-memory extraction agent and the `/memory inbox` review flow. The
|
||||
* main agent is denied access to it even though it falls inside the project
|
||||
* temp dir; the extraction agent receives a narrow execution-scoped exception
|
||||
* for *writes* to `.inbox/{private,global}/extraction.patch`. Scoped *read*
|
||||
* access to the wider `.inbox/{private,global}/` subtree is granted in
|
||||
* `validatePathAccess` so the extractor can enumerate prior patches.
|
||||
* for `.inbox/{private,global}/extraction.patch`.
|
||||
*
|
||||
* @param absolutePath The absolute path to check.
|
||||
* @returns true if the path is allowed, false otherwise.
|
||||
@@ -3282,28 +3265,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
if (this.getWorkspaceContext().isPathReadable(absolutePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The memory inbox is carved out of the standard temp-dir allowlist by
|
||||
// `isPathAllowed`. The extraction agent is granted a scoped read
|
||||
// exception so it can enumerate prior patches (including non-canonical
|
||||
// filenames) before consolidating them into the canonical
|
||||
// extraction.patch. Writes remain restricted to canonical paths.
|
||||
if (hasScopedMemoryInboxAccess()) {
|
||||
const inboxRoot = path.join(
|
||||
this.storage.getProjectMemoryTempDir(),
|
||||
'.inbox',
|
||||
);
|
||||
if (
|
||||
this.isScopedMemoryInboxPatchPathAllowed(
|
||||
absolutePath,
|
||||
resolveToRealPath(absolutePath),
|
||||
inboxRoot,
|
||||
'read',
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then check standard allowed paths (Workspace + Temp)
|
||||
|
||||
@@ -345,7 +345,7 @@ export function isGemini3Model(
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
// Legacy behavior resolves the model first.
|
||||
const resolved = resolveModel(model, false, false, false, true, config);
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.family ===
|
||||
'gemini-3'
|
||||
|
||||
@@ -374,65 +374,4 @@ describe('ProjectRegistry', () => {
|
||||
|
||||
readFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('recovers gracefully if registry is an empty object (invalid schema)', async () => {
|
||||
// 1. Write an empty object which is valid JSON but invalid schema
|
||||
fs.writeFileSync(registryPath, '{}');
|
||||
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
// 2. It should not crash and should allow adding new projects
|
||||
const projectPath = path.join(tempDir, 'my-project');
|
||||
const shortId = await registry.getShortId(projectPath);
|
||||
|
||||
expect(shortId).toBe('my-project');
|
||||
|
||||
// 3. Verify it healed the file
|
||||
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(data.projects).toBeDefined();
|
||||
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
|
||||
});
|
||||
|
||||
it('recovers gracefully if registry projects property is an array (invalid schema)', async () => {
|
||||
// 1. Write an object where 'projects' is an array
|
||||
fs.writeFileSync(registryPath, JSON.stringify({ projects: [] }));
|
||||
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
// 2. It should reset and allow adding new projects correctly
|
||||
const projectPath = path.join(tempDir, 'my-project');
|
||||
const shortId = await registry.getShortId(projectPath);
|
||||
|
||||
expect(shortId).toBe('my-project');
|
||||
|
||||
// 3. Verify it healed the file to an object
|
||||
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(data.projects).toBeDefined();
|
||||
expect(Array.isArray(data.projects)).toBe(false);
|
||||
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
|
||||
});
|
||||
|
||||
it('recovers gracefully if registry contains malicious slugs (path traversal)', async () => {
|
||||
// 1. Write a registry with a path traversal slug
|
||||
fs.writeFileSync(
|
||||
registryPath,
|
||||
JSON.stringify({ projects: { '/some/path': '../../etc/passwd' } }),
|
||||
);
|
||||
|
||||
const registry = new ProjectRegistry(registryPath);
|
||||
await registry.initialize();
|
||||
|
||||
// 2. It should identify as invalid and reset
|
||||
const projectPath = path.join(tempDir, 'my-project');
|
||||
const shortId = await registry.getShortId(projectPath);
|
||||
|
||||
expect(shortId).toBe('my-project');
|
||||
|
||||
// 3. Verify it healed the file and didn't preserve the malicious entry
|
||||
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
||||
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
|
||||
expect(Object.values(data.projects)).not.toContain('../../etc/passwd');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,6 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import { z } from 'zod';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
|
||||
@@ -17,10 +16,6 @@ export interface RegistryData {
|
||||
projects: Record<string, string>;
|
||||
}
|
||||
|
||||
const registryDataSchema = z.object({
|
||||
projects: z.record(z.string(), z.string().regex(/^[a-z0-9-]+$/)),
|
||||
});
|
||||
|
||||
const PROJECT_ROOT_FILE = '.project_root';
|
||||
const LOCK_TIMEOUT_MS = 10000;
|
||||
const LOCK_RETRY_DELAY_MS = 100;
|
||||
@@ -62,16 +57,8 @@ export class ProjectRegistry {
|
||||
private async loadData(): Promise<RegistryData> {
|
||||
try {
|
||||
const content = await fs.promises.readFile(this.registryPath, 'utf8');
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
|
||||
if (this.isValidRegistryData(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
debugLogger.warn(
|
||||
`Project registry at ${this.registryPath} has an invalid schema, resetting to empty.`,
|
||||
);
|
||||
return { projects: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return JSON.parse(content);
|
||||
} catch (error: unknown) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return { projects: {} }; // Normal first run
|
||||
@@ -420,8 +407,4 @@ export class ProjectRegistry {
|
||||
.replace(/^-|-$/g, '') || 'project'
|
||||
);
|
||||
}
|
||||
|
||||
private isValidRegistryData(data: unknown): data is RegistryData {
|
||||
return registryDataSchema.safeParse(data).success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ describe('ContextManager Sync Pressure Barrier Tests', () => {
|
||||
]);
|
||||
|
||||
// 3. Add massive history that blows past the 150k maxTokens limit
|
||||
// 20 turns * ~20,000 tokens/turn (10k user + 10k model) = ~400,000 tokens
|
||||
const massiveHistory = createSyntheticHistory(20, 10000);
|
||||
// 20 turns * 10,000 tokens/turn = ~200,000 tokens
|
||||
const massiveHistory = createSyntheticHistory(20, 35000);
|
||||
chatHistory.set([...chatHistory.get(), ...massiveHistory]);
|
||||
|
||||
// 4. Add the Latest Turn (Protected)
|
||||
@@ -60,8 +60,8 @@ 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');
|
||||
const projectionString = JSON.stringify(projection);
|
||||
expect(projectionString).toContain('User turn 17');
|
||||
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(
|
||||
(p) =>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
createMockContextConfig,
|
||||
setupContextComponentTest,
|
||||
createMockLlmClient,
|
||||
} from './testing/contextTestUtils.js';
|
||||
import { stressTestProfile } from './config/profiles.js';
|
||||
|
||||
describe('ContextManager - Hot Start Calibration', () => {
|
||||
it('should not perform calibration if the buffer is empty', async () => {
|
||||
const mockLlm = createMockLlmClient();
|
||||
const config = createMockContextConfig(undefined, mockLlm);
|
||||
const { contextManager } = setupContextComponentTest(
|
||||
config,
|
||||
stressTestProfile,
|
||||
);
|
||||
|
||||
// We can spy on the underlying mock LLM client countTokens
|
||||
const countTokensSpy = vi.spyOn(mockLlm, 'countTokens');
|
||||
|
||||
// Render an empty graph
|
||||
await contextManager.renderHistory();
|
||||
|
||||
expect(countTokensSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should perform calibration exactly once when rendering with existing nodes', async () => {
|
||||
const mockLlm = createMockLlmClient();
|
||||
const countTokensSpy = vi
|
||||
.spyOn(mockLlm, 'countTokens')
|
||||
.mockResolvedValue({ totalTokens: 42 });
|
||||
|
||||
const config = createMockContextConfig(undefined, mockLlm);
|
||||
const { contextManager, chatHistory } = setupContextComponentTest(
|
||||
config,
|
||||
stressTestProfile,
|
||||
);
|
||||
|
||||
// We need to access the env's eventBus inside the contextManager
|
||||
const env = Reflect.get(contextManager, 'env');
|
||||
const emitGroundTruthSpy = vi.spyOn(env.eventBus, 'emitTokenGroundTruth');
|
||||
|
||||
// Add a node to make the buffer non-empty
|
||||
chatHistory.set([{ role: 'user', parts: [{ text: 'Hello' }] }]);
|
||||
|
||||
// First render should trigger calibration
|
||||
await contextManager.renderHistory();
|
||||
|
||||
expect(countTokensSpy).toHaveBeenCalledTimes(1);
|
||||
expect(emitGroundTruthSpy).toHaveBeenCalledTimes(1);
|
||||
expect(emitGroundTruthSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actualTokens: 42,
|
||||
promptBaseUnits: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
// Second render should skip calibration
|
||||
await contextManager.renderHistory();
|
||||
expect(countTokensSpy).toHaveBeenCalledTimes(1);
|
||||
// emit hasn't been called again
|
||||
expect(emitGroundTruthSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should silently swallow errors if countTokens API fails', async () => {
|
||||
const mockLlm = createMockLlmClient();
|
||||
const countTokensSpy = vi
|
||||
.spyOn(mockLlm, 'countTokens')
|
||||
.mockRejectedValue(new Error('API failure'));
|
||||
|
||||
const config = createMockContextConfig(undefined, mockLlm);
|
||||
const { contextManager, chatHistory } = setupContextComponentTest(
|
||||
config,
|
||||
stressTestProfile,
|
||||
);
|
||||
|
||||
// Add a node
|
||||
chatHistory.set([{ role: 'user', parts: [{ text: 'Hello' }] }]);
|
||||
|
||||
// Render should succeed without throwing
|
||||
const result = await contextManager.renderHistory();
|
||||
|
||||
expect(result.history).toBeDefined();
|
||||
expect(countTokensSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { hardenHistory } from '../utils/historyHardening.js';
|
||||
import { checkContextInvariants } from './utils/invariantChecker.js';
|
||||
import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js';
|
||||
|
||||
export class ContextManager {
|
||||
// The master state containing the pristine graph and current active graph.
|
||||
@@ -37,22 +36,15 @@ export class ContextManager {
|
||||
// Cache for Anomaly 3 (Redundant Renders)
|
||||
private lastRenderCache?: {
|
||||
nodesHash: string;
|
||||
result: {
|
||||
history: Content[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
};
|
||||
result: { history: Content[]; didApplyManagement: boolean };
|
||||
};
|
||||
|
||||
private hasPerformedHotStart = false;
|
||||
|
||||
constructor(
|
||||
private readonly sidecar: ContextProfile,
|
||||
private readonly env: ContextEnvironment,
|
||||
private readonly tracer: ContextTracer,
|
||||
orchestrator: PipelineOrchestrator,
|
||||
chatHistory: AgentChatHistory,
|
||||
private readonly advancedTokenCalculator: AdvancedTokenCalculator,
|
||||
private readonly headerProvider?: () => Promise<Content | undefined>,
|
||||
) {
|
||||
this.eventBus = env.eventBus;
|
||||
@@ -268,10 +260,6 @@ export class ContextManager {
|
||||
return [...this.buffer.nodes];
|
||||
}
|
||||
|
||||
getEnvironment(): ContextEnvironment {
|
||||
return this.env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the final 'gc_backstop' pipeline if necessary, enforcing the token budget,
|
||||
* and maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
|
||||
@@ -280,44 +268,22 @@ export class ContextManager {
|
||||
async renderHistory(
|
||||
pendingRequest?: Content,
|
||||
activeTaskIds: Set<string> = new Set(),
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<{
|
||||
history: Content[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
}> {
|
||||
): Promise<{ history: Content[]; didApplyManagement: boolean }> {
|
||||
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
|
||||
|
||||
let previewNodes: ConcreteNode[] = [];
|
||||
if (pendingRequest) {
|
||||
previewNodes = this.env.graphMapper.applyEvent({
|
||||
type: 'PUSH',
|
||||
payload: [pendingRequest],
|
||||
});
|
||||
}
|
||||
|
||||
// --- Hot Start Calibration ---
|
||||
// If we are resuming a session with history, we don't want the adaptive token calculator
|
||||
// to fly blind on its first GC pass. We do a one-time API calibration.
|
||||
const hotStartPromise = (async () => {
|
||||
if (!this.hasPerformedHotStart) {
|
||||
this.hasPerformedHotStart = true;
|
||||
if (this.buffer.nodes.length > 0) {
|
||||
const nodesForHotStart = [...this.buffer.nodes, ...previewNodes];
|
||||
await this.performHotStartCalibration(nodesForHotStart, abortSignal);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// 1. Synchronous Pressure Barrier: Wait for background management pipelines to finish.
|
||||
// We run hot start calibration in parallel to hide the network latency.
|
||||
await Promise.all([this.orchestrator.waitForPipelines(), hotStartPromise]);
|
||||
// This ensures that the render sees the results of recent pushes (Anomaly 2).
|
||||
await this.orchestrator.waitForPipelines();
|
||||
|
||||
let nodes = this.buffer.nodes;
|
||||
const previewNodeIds = new Set<string>();
|
||||
|
||||
// Apply the preview nodes to the final graph
|
||||
if (previewNodes.length > 0) {
|
||||
// If we have a pending request, we need to build a 'preview' graph for this render.
|
||||
if (pendingRequest) {
|
||||
const previewNodes = this.env.graphMapper.applyEvent({
|
||||
type: 'PUSH',
|
||||
payload: [pendingRequest],
|
||||
});
|
||||
for (const n of previewNodes) {
|
||||
previewNodeIds.add(n.id);
|
||||
}
|
||||
@@ -328,6 +294,9 @@ export class ContextManager {
|
||||
const header = this.headerProvider
|
||||
? await this.headerProvider()
|
||||
: undefined;
|
||||
const headerTokens = header
|
||||
? this.env.tokenCalculator.calculateContentTokens(header)
|
||||
: 0;
|
||||
|
||||
// 3. Cache Check (Anomaly 3): If nodes haven't changed, return previous result.
|
||||
// We combine the graph hash with a hash of the header to ensure total freshness.
|
||||
@@ -345,19 +314,14 @@ export class ContextManager {
|
||||
const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds);
|
||||
|
||||
// Apply final GC Backstop pressure barrier synchronously before mapping
|
||||
const {
|
||||
history: renderedHistory,
|
||||
didApplyManagement,
|
||||
baseUnits,
|
||||
} = await render(
|
||||
const { history: renderedHistory, didApplyManagement } = await render(
|
||||
nodes,
|
||||
this.orchestrator,
|
||||
this.sidecar,
|
||||
this.tracer,
|
||||
this.env,
|
||||
this.advancedTokenCalculator,
|
||||
protectionReasons,
|
||||
header,
|
||||
headerTokens,
|
||||
previewNodeIds,
|
||||
);
|
||||
|
||||
@@ -375,58 +339,10 @@ export class ContextManager {
|
||||
sentinels: this.sidecar.sentinels,
|
||||
}),
|
||||
didApplyManagement,
|
||||
baseUnits,
|
||||
};
|
||||
|
||||
// Update cache
|
||||
this.lastRenderCache = { nodesHash: totalHash, result };
|
||||
return result;
|
||||
}
|
||||
|
||||
private async performHotStartCalibration(
|
||||
nodes: readonly ConcreteNode[],
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
try {
|
||||
this.tracer.logEvent(
|
||||
'ContextManager',
|
||||
'Performing Hot Start Token Calibration',
|
||||
);
|
||||
|
||||
const contents = this.env.graphMapper.fromGraph(nodes);
|
||||
const header = this.headerProvider
|
||||
? await this.headerProvider()
|
||||
: undefined;
|
||||
const combinedHistory = header ? [header, ...contents] : contents;
|
||||
|
||||
const baseUnits =
|
||||
this.advancedTokenCalculator.getRawBaseUnits(nodes) +
|
||||
(header
|
||||
? this.advancedTokenCalculator.getRawBaseUnitsForContent(header)
|
||||
: 0);
|
||||
|
||||
// We only make the network call if we have actual contents to send,
|
||||
// avoiding 400 Bad Request errors from the API.
|
||||
if (combinedHistory.length > 0) {
|
||||
const result = await this.env.llmClient.countTokens({
|
||||
contents: combinedHistory,
|
||||
abortSignal,
|
||||
});
|
||||
if (result.totalTokens > 0) {
|
||||
this.env.eventBus.emitTokenGroundTruth({
|
||||
actualTokens: result.totalTokens,
|
||||
promptBaseUnits: baseUnits,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Hot start calibration is purely an optimization. If the network fails or auth is weird,
|
||||
// we silently swallow and fallback to the un-calibrated 1.0 ratio heuristic.
|
||||
this.tracer.logEvent(
|
||||
'ContextManager',
|
||||
'Hot Start Token Calibration Failed (Ignored)',
|
||||
{ error },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,20 +29,7 @@ export interface ChunkReceivedEvent {
|
||||
targetNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export interface TokenGroundTruthEvent {
|
||||
actualTokens: number;
|
||||
promptBaseUnits: number;
|
||||
}
|
||||
|
||||
export class ContextEventBus extends EventEmitter {
|
||||
emitTokenGroundTruth(event: TokenGroundTruthEvent) {
|
||||
this.emit('TOKEN_GROUND_TRUTH', event);
|
||||
}
|
||||
|
||||
onTokenGroundTruth(listener: (event: TokenGroundTruthEvent) => void) {
|
||||
this.on('TOKEN_GROUND_TRUTH', listener);
|
||||
}
|
||||
|
||||
emitPristineHistoryUpdated(event: PristineHistoryUpdatedEvent) {
|
||||
this.emit('PRISTINE_HISTORY_UPDATED', event);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from './render.js';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import { NodeType } from './types.js';
|
||||
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import type { ContextEnvironment } from '../pipeline/environment.js';
|
||||
import type { ContextTracer } from '../tracer.js';
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
@@ -38,20 +37,7 @@ describe('render', () => {
|
||||
|
||||
const orchestrator = {} as PipelineOrchestrator;
|
||||
const sidecar = { config: {} } as ContextProfile; // No budget
|
||||
const mockAdvancedTokenCalculator = {
|
||||
calculateTokensAndBaseUnits: vi.fn().mockReturnValue({
|
||||
tokens: 100,
|
||||
baseUnits: 100,
|
||||
}),
|
||||
getRawBaseUnits: vi.fn().mockReturnValue(100),
|
||||
getRawBaseUnitsForContent: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
|
||||
const env = {
|
||||
tokenCalculator: {
|
||||
calculateConcreteListTokens: vi.fn().mockReturnValue(100),
|
||||
calculateTokenBreakdown: vi.fn().mockReturnValue({}),
|
||||
},
|
||||
graphMapper: {
|
||||
fromGraph: vi.fn((nodes: readonly ConcreteNode[]) =>
|
||||
nodes.map((n) => ({ text: n.id })),
|
||||
@@ -68,14 +54,12 @@ describe('render', () => {
|
||||
sidecar,
|
||||
tracer,
|
||||
env,
|
||||
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
|
||||
new Map(),
|
||||
undefined,
|
||||
0,
|
||||
previewNodeIds,
|
||||
);
|
||||
|
||||
expect(result.history).toEqual([{ text: '1' }, { text: '2' }]);
|
||||
expect(result.baseUnits).toBe(100);
|
||||
});
|
||||
|
||||
it('simulates the boundary knapsack problem (loose boundary policy)', async () => {
|
||||
@@ -124,24 +108,12 @@ describe('render', () => {
|
||||
|
||||
const currentTokens = 160000;
|
||||
|
||||
const mockAdvancedTokenCalculator = {
|
||||
calculateTokensAndBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => {
|
||||
const tokens =
|
||||
nodes.length === 1 ? tokenMap[nodes[0].id] : currentTokens;
|
||||
return { tokens, baseUnits: tokens };
|
||||
}),
|
||||
getRawBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
};
|
||||
|
||||
const env = {
|
||||
llmClient: {
|
||||
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }),
|
||||
},
|
||||
tokenCalculator: {
|
||||
calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => {
|
||||
calculateConcreteListTokens: vi.fn((nodes) => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
@@ -164,9 +136,8 @@ describe('render', () => {
|
||||
sidecar,
|
||||
tracer,
|
||||
env,
|
||||
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
|
||||
new Map(),
|
||||
undefined,
|
||||
0,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
@@ -176,7 +147,6 @@ describe('render', () => {
|
||||
// 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
|
||||
expect(result.baseUnits).toBe(160000);
|
||||
});
|
||||
|
||||
it('drops nodes that are STRICTLY older than the boundary node', async () => {
|
||||
@@ -218,24 +188,12 @@ describe('render', () => {
|
||||
|
||||
const currentTokens = 160000;
|
||||
|
||||
const mockAdvancedTokenCalculator = {
|
||||
calculateTokensAndBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => {
|
||||
const tokens =
|
||||
nodes.length === 1 ? tokenMap[nodes[0].id] : currentTokens;
|
||||
return { tokens, baseUnits: tokens };
|
||||
}),
|
||||
getRawBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
};
|
||||
|
||||
const env = {
|
||||
llmClient: {
|
||||
countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }),
|
||||
},
|
||||
tokenCalculator: {
|
||||
calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => {
|
||||
calculateConcreteListTokens: vi.fn((nodes) => {
|
||||
if (nodes.length === 1) return tokenMap[nodes[0].id];
|
||||
return currentTokens;
|
||||
}),
|
||||
@@ -258,9 +216,8 @@ describe('render', () => {
|
||||
sidecar,
|
||||
tracer,
|
||||
env,
|
||||
mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator,
|
||||
new Map(),
|
||||
undefined,
|
||||
0,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
@@ -268,6 +225,5 @@ describe('render', () => {
|
||||
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
|
||||
expect(result.baseUnits).toBe(160000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ 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';
|
||||
import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
|
||||
/**
|
||||
* Maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission.
|
||||
@@ -23,43 +22,21 @@ export async function render(
|
||||
sidecar: ContextProfile,
|
||||
tracer: ContextTracer,
|
||||
env: ContextEnvironment,
|
||||
advancedTokenCalculator: AdvancedTokenCalculator,
|
||||
protectionReasons: Map<string, string> = new Map(),
|
||||
header?: Content,
|
||||
headerTokens: number = 0,
|
||||
previewNodeIds: ReadonlySet<string> = new Set(),
|
||||
): Promise<{
|
||||
history: Content[];
|
||||
didApplyManagement: boolean;
|
||||
baseUnits: number;
|
||||
}> {
|
||||
let headerTokens = 0;
|
||||
let headerBaseUnits = 0;
|
||||
if (header) {
|
||||
const costs =
|
||||
advancedTokenCalculator.calculateContentTokensAndBaseUnits(header);
|
||||
headerTokens = costs.tokens;
|
||||
headerBaseUnits = costs.baseUnits;
|
||||
}
|
||||
|
||||
): Promise<{ history: Content[]; didApplyManagement: boolean }> {
|
||||
if (!sidecar.config.budget) {
|
||||
const visibleNodes = nodes.filter((n) => !previewNodeIds.has(n.id));
|
||||
const contents = env.graphMapper.fromGraph(visibleNodes);
|
||||
tracer.logEvent('Render', 'Render Context to LLM (No Budget)', {
|
||||
renderedContext: contents,
|
||||
});
|
||||
|
||||
// In all cases, retrieve raw base units from the token calculator interface
|
||||
const baseUnits =
|
||||
advancedTokenCalculator.getRawBaseUnits(nodes) + headerBaseUnits;
|
||||
|
||||
return { history: contents, didApplyManagement: false, baseUnits };
|
||||
return { history: contents, didApplyManagement: false };
|
||||
}
|
||||
|
||||
const maxTokens = sidecar.config.budget.maxTokens;
|
||||
|
||||
const { tokens: graphTokens, baseUnits: graphBaseUnits } =
|
||||
advancedTokenCalculator.calculateTokensAndBaseUnits(nodes);
|
||||
|
||||
const graphTokens = env.tokenCalculator.calculateConcreteListTokens(nodes);
|
||||
const currentTokens = graphTokens + headerTokens;
|
||||
|
||||
const protectedIds = new Set(protectionReasons.keys());
|
||||
@@ -93,11 +70,7 @@ export async function render(
|
||||
renderedContext: contents,
|
||||
});
|
||||
performCalibration(env, visibleNodes, contents);
|
||||
return {
|
||||
history: contents,
|
||||
didApplyManagement: false,
|
||||
baseUnits: graphBaseUnits + headerBaseUnits,
|
||||
};
|
||||
return { history: contents, didApplyManagement: false };
|
||||
}
|
||||
const targetDelta = currentTokens - sidecar.config.budget.retainedTokens;
|
||||
tracer.logEvent(
|
||||
@@ -146,10 +119,5 @@ export async function render(
|
||||
renderedContextSanitized: contents,
|
||||
});
|
||||
performCalibration(env, visibleNodes, contents);
|
||||
return {
|
||||
history: contents,
|
||||
didApplyManagement: true,
|
||||
baseUnits:
|
||||
advancedTokenCalculator.getRawBaseUnits(visibleNodes) + headerBaseUnits,
|
||||
};
|
||||
return { history: contents, didApplyManagement: true };
|
||||
}
|
||||
|
||||